From 9f971f53379c33a5eb3bc2dae382c8852bb66aff Mon Sep 17 00:00:00 2001 From: sarahdomingos Date: Tue, 19 May 2026 13:46:52 -0300 Subject: [PATCH 01/69] feat: step 4 funcionando e checkin enviando payload --- .../checkin-step-intensity-component.css | 37 ++++++ .../checkin-step-intensity-component.html | 120 +++++++++++++++++- .../checkin-step-intensity-component.ts | 119 ++++++++++++++++- .../src/app/features/checkin/checkin.html | 23 +--- frontend/src/app/features/checkin/checkin.ts | 4 + 5 files changed, 283 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.css b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.css index e69de29..e1c3990 100644 --- a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.css +++ b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.css @@ -0,0 +1,37 @@ +.slider { + background-repeat: no-repeat; + border-radius: 9999px; +} + +.slider::-webkit-slider-runnable-track { + height: 8px; + border-radius: 9999px; + background: transparent; +} + +.slider::-moz-range-track { + height: 8px; + border-radius: 9999px; + background: transparent; +} + +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + margin-top: -7px; + width: 22px; + height: 22px; + border-radius: 9999px; + background: #ffffff; + border: 4px solid currentColor; + box-shadow: 0 2px 10px rgba(55, 56, 49, 0.18); +} + +.slider::-moz-range-thumb { + width: 22px; + height: 22px; + border-radius: 9999px; + background: #ffffff; + border: 4px solid currentColor; + box-shadow: 0 2px 10px rgba(55, 56, 49, 0.18); +} \ No newline at end of file diff --git a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.html b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.html index 04e65a4..433ef3b 100644 --- a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.html +++ b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.html @@ -1 +1,119 @@ -

checkin-step-intensity-component works!

+
+
+

+ Qual a intensidade dos sintomas hoje? +

+ +

+ Arraste a escala para indicar o impacto geral dos sintomas no seu dia. +

+
+ +
+
+ + {{ displayScaleValue }} + + + + {{ intensityLabel }} + + + + Escolha de 1 a 10 + +
+
+ +
+ + +
+ 1 - Nenhuma + 10 - Severa +
+
+ +
+ Que ótimo! Continue firme no tratamento para mais dias sem sintomas! +
+ +
+
+
😌
+

Leve

+

1-3

+
+ +
+
😮
+

Moderada

+

4-6

+
+ +
+
😣
+

Severa

+

7-10

+
+
+ +

+ Selecione a intensidade dos sintomas para continuar. +

+
\ No newline at end of file diff --git a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.ts b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.ts index 5228632..de61c96 100644 --- a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.ts +++ b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.ts @@ -1,9 +1,122 @@ -import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; + +type IntensityVisual = { + label: string; + description: string; + circleClass: string; + valueClass: string; + labelClass: string; + activeColor: string; +}; @Component({ selector: 'app-checkin-step-intensity-component', - imports: [], + standalone: true, + imports: [CommonModule, ReactiveFormsModule], templateUrl: './checkin-step-intensity-component.html', styleUrl: './checkin-step-intensity-component.css', }) -export class CheckinStepIntensityComponent {} +export class CheckinStepIntensityComponent { + @Input({ required: true }) form!: FormGroup; + + get scaleValue(): number | null { + return this.form.get('scale')?.value ?? null; + } + + get hasSelectedIntensity(): boolean { + return this.scaleValue !== null; + } + + get displayScaleValue(): string { + return this.scaleValue === null ? '' : String(this.scaleValue); + } + + get sliderValue(): number { + return this.scaleValue ?? 1; + } + + get showError(): boolean { + const control = this.form.get('scale'); + return !!control && control.invalid && (control.touched || control.dirty); + } + + get hasNoSymptomsIntensity(): boolean { + return this.scaleValue === 1; + } + + get intensityVisual(): IntensityVisual { + const value = this.scaleValue; + + if (value === null) { + return { + label: 'Selecione', + description: 'Escolha um valor de 1 a 10', + circleClass: 'border-[#D9D4CE] bg-[#F7F5F1]', + valueClass: 'text-[#9A968F]', + labelClass: 'text-[#9A968F]', + activeColor: '#D9D4CE', + }; + } + + if (value <= 3) { + return { + label: value === 1 ? 'Nenhuma' : 'Leve', + description: 'Sintomas leves', + circleClass: 'border-[#5A7B63] bg-[#F1F0EA]', + valueClass: 'text-[#5A7B63]', + labelClass: 'text-[#5A7B63]', + activeColor: '#5A7B63', + }; + } + + if (value <= 6) { + return { + label: 'Moderada', + description: 'Atenção aos sinais', + circleClass: 'border-[#C47B33] bg-[#F7EFE4]', + valueClass: 'text-[#C47B33]', + labelClass: 'text-[#C47B33]', + activeColor: '#C47B33', + }; + } + + return { + label: 'Severa', + description: 'Impacto elevado', + circleClass: 'border-[#B05A5A] bg-[#F8EAEA]', + valueClass: 'text-[#B05A5A]', + labelClass: 'text-[#B05A5A]', + activeColor: '#B05A5A', + }; + } + + get intensityLabel(): string { + return this.intensityVisual.label; + } + + get intensityDescription(): string { + return this.intensityVisual.description; + } + + get sliderFillPercentage(): number { + return ((this.sliderValue - 1) / 9) * 100; + } + + get sliderTrackStyle(): string { + const fill = this.sliderFillPercentage; + const active = this.intensityVisual.activeColor; + const inactive = '#D9D4CE'; + + return `linear-gradient(to right, ${active} 0%, ${active} ${fill}%, ${inactive} ${fill}%, ${inactive} 100%)`; + } + + onScaleChange(event: Event): void { + const value = Number((event.target as HTMLInputElement).value); + + this.form.get('scale')?.setValue(value); + this.form.get('scale')?.markAsDirty(); + this.form.get('scale')?.markAsTouched(); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/checkin/checkin.html b/frontend/src/app/features/checkin/checkin.html index 9983bf5..e1534b7 100644 --- a/frontend/src/app/features/checkin/checkin.html +++ b/frontend/src/app/features/checkin/checkin.html @@ -37,12 +37,11 @@

Check-in

*ngIf="currentStep() === 3" [form]="detailsForm" > - + [form]="intensityForm" + >
- -
diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index fc9a49a..77d5dee 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -89,6 +89,10 @@ export class CheckinComponent { return this.form.get('intensity') as FormGroup; } + get isCurrentStepInvalid(): boolean { + return this.getCurrentStepForm().invalid; +} + isStepActive(stepId: number): boolean { return this.currentStep() === stepId; } From 3504952d326db3120506d24da6aada529d407385 Mon Sep 17 00:00:00 2001 From: sarahdomingos Date: Wed, 20 May 2026 11:20:44 -0300 Subject: [PATCH 02/69] fix: ao fim do checkin, redirect para a tela home --- frontend/src/app/features/checkin/checkin.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index d3f9f95..ada9a76 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -10,6 +10,7 @@ import { CheckinStepFeelingComponent } from '../../components/checkin-step-feeli import { CheckinStepSymptomsComponent } from '../../components/checkin-step-symptoms-component/checkin-step-symptoms-component'; import { CheckinStepIntensityComponent } from '../../components/checkin-step-intensity-component/checkin-step-intensity-component'; import { CheckinStepDetailsComponent } from '../../components/checkin-step-details-component/checkin-step-details-component'; +import { Router } from '@angular/router'; type StepItem = { @@ -33,6 +34,7 @@ type StepItem = { }) export class CheckinComponent { private readonly fb = inject(FormBuilder); + private router = inject(Router); steps: StepItem[] = [ { id: 1, label: 'Ranking de Sentimentos' }, { id: 2, label: 'Seleção de Sintomas' }, @@ -157,6 +159,7 @@ export class CheckinComponent { }, }; console.log('Payload final do check-in:', payload); + this.router.navigate(['home']); } private getCurrentStepForm(): FormGroup { From cf59178ce933c938e2811177233df1ef55b77ac9 Mon Sep 17 00:00:00 2001 From: sarahdomingos Date: Wed, 20 May 2026 13:21:57 -0300 Subject: [PATCH 03/69] =?UTF-8?q?test:=20teste=20unit=C3=A1rio=20para=20a?= =?UTF-8?q?=20componente=20pai=20checkin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkin-step-intensity-component.spec.ts | 292 +++++++++++- .../src/app/features/checkin/checkin.html | 2 +- .../src/app/features/checkin/checkin.spec.ts | 440 +++++++++++++++++- frontend/src/app/features/checkin/checkin.ts | 50 +- 4 files changed, 753 insertions(+), 31 deletions(-) diff --git a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.spec.ts b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.spec.ts index f20562b..e239075 100644 --- a/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.spec.ts +++ b/frontend/src/app/components/checkin-step-intensity-component/checkin-step-intensity-component.spec.ts @@ -1,22 +1,304 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { By } from '@angular/platform-browser'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { CheckinStepIntensityComponent } from './checkin-step-intensity-component'; -describe('CheckinStepIntensityComponent', () => { - let component: CheckinStepIntensityComponent; +describe(CheckinStepIntensityComponent.name, () => { let fixture: ComponentFixture; + let component: CheckinStepIntensityComponent; + let form: FormGroup; + + const getByTestId = (testId: string) => + fixture.debugElement.query(By.css(`[data-testid="${testId}"]`)); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CheckinStepIntensityComponent], }).compileComponents(); + }); + + beforeEach(() => { + form = new FormGroup({ + scale: new FormControl(null, Validators.required), + }); fixture = TestBed.createComponent(CheckinStepIntensityComponent); component = fixture.componentInstance; - await fixture.whenStable(); + component.form = form; + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); -}); + + it('should render intensity step container', () => { + expect(getByTestId('intensity-step')).toBeTruthy(); + }); + + it('should render title and description', () => { + const title = getByTestId('intensity-title'); + const description = getByTestId('intensity-description'); + + expect(title).toBeTruthy(); + expect(description).toBeTruthy(); + expect((title.nativeElement as HTMLElement).textContent).toContain('Qual a intensidade dos sintomas hoje?'); + expect((description.nativeElement as HTMLElement).textContent).toContain('Arraste a escala para indicar o impacto geral dos sintomas no seu dia.'); + }); + + it('should render slider and legend blocks', () => { + expect(getByTestId('intensity-slider-block')).toBeTruthy(); + expect(getByTestId('intensity-slider')).toBeTruthy(); + expect(getByTestId('intensity-legend')).toBeTruthy(); + expect(getByTestId('intensity-legend-mild')).toBeTruthy(); + expect(getByTestId('intensity-legend-moderate')).toBeTruthy(); + expect(getByTestId('intensity-legend-severe')).toBeTruthy(); + }); + + it('should start with neutral state when scale is null', () => { + expect(component.scaleValue).toBeNull(); + expect(component.hasSelectedIntensity).toBeFalsy(); + expect(component.displayScaleValue).toBe(''); + expect(component.sliderValue).toBe(1); + expect(component.intensityLabel).toBe('Selecione'); + expect(getByTestId('intensity-hint')).toBeTruthy(); + expect(getByTestId('intensity-success-message')).toBeNull(); + }); + + it('should render empty displayed value when scale is null', () => { + const valueEl = getByTestId('intensity-value').nativeElement as HTMLElement; + expect(valueEl.textContent?.trim()).toBe(''); + }); + + it('should render neutral classes when scale is null', () => { + const indicator = getByTestId('intensity-indicator').nativeElement as HTMLElement; + const label = getByTestId('intensity-label').nativeElement as HTMLElement; + + expect(indicator.className).toContain('border-[#D9D4CE]'); + expect(indicator.className).toContain('bg-[#F7F5F1]'); + expect(label.className).toContain('text-[#9A968F]'); + }); + + it('should update form control when slider changes', () => { + const slider = getByTestId('intensity-slider').nativeElement as HTMLInputElement; + + slider.value = '6'; + slider.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(form.get('scale')?.value).toBe(6); + }); + + it('should mark scale control as dirty and touched when slider changes', () => { + const control = form.get('scale'); + const slider = getByTestId('intensity-slider').nativeElement as HTMLInputElement; + + expect(control?.dirty).toBeFalsy(); + expect(control?.touched).toBeFalsy(); + + slider.value = '5'; + slider.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(control?.dirty).toBeTruthy(); + expect(control?.touched).toBeTruthy(); + }); + + it('should hide hint after selecting intensity', () => { + form.get('scale')?.setValue(4); + fixture.detectChanges(); + + expect(component.hasSelectedIntensity).toBeTruthy(); + expect(getByTestId('intensity-hint')).toBeNull(); + }); + + it('should show success message when intensity is 1', () => { + form.get('scale')?.setValue(1); + fixture.detectChanges(); + + expect(component.hasNoSymptomsIntensity).toBeTruthy(); + expect(getByTestId('intensity-success-message')).toBeTruthy(); + }); + + it('should not show success message when intensity is greater than 1', () => { + form.get('scale')?.setValue(2); + fixture.detectChanges(); + + expect(component.hasNoSymptomsIntensity).toBeFalsy(); + expect(getByTestId('intensity-success-message')).toBeNull(); + }); + + it('should display selected value in the circle', () => { + form.get('scale')?.setValue(7); + fixture.detectChanges(); + + const valueEl = getByTestId('intensity-value').nativeElement as HTMLElement; + expect(valueEl.textContent?.trim()).toBe('7'); + }); + + it('should return "Nenhuma" label for intensity 1', () => { + form.get('scale')?.setValue(1); + fixture.detectChanges(); + + expect(component.intensityLabel).toBe('Nenhuma'); + + const labelEl = getByTestId('intensity-label').nativeElement as HTMLElement; + expect(labelEl.textContent?.trim()).toBe('Nenhuma'); + }); + + it('should return "Leve" label for intensity 2 or 3', () => { + form.get('scale')?.setValue(3); + fixture.detectChanges(); + + expect(component.intensityLabel).toBe('Leve'); + }); + + it('should return "Moderada" label for intensity between 4 and 6', () => { + form.get('scale')?.setValue(5); + fixture.detectChanges(); + + expect(component.intensityLabel).toBe('Moderada'); + }); + + it('should return "Severa" label for intensity between 7 and 10', () => { + form.get('scale')?.setValue(9); + fixture.detectChanges(); + + expect(component.intensityLabel).toBe('Severa'); + }); + + it('should apply mild styles when intensity is between 1 and 3', () => { + form.get('scale')?.setValue(2); + fixture.detectChanges(); + + const indicator = getByTestId('intensity-indicator').nativeElement as HTMLElement; + const valueEl = getByTestId('intensity-value').nativeElement as HTMLElement; + const labelEl = getByTestId('intensity-label').nativeElement as HTMLElement; + + expect(indicator.className).toContain('border-[#5A7B63]'); + expect(indicator.className).toContain('bg-[#F1F0EA]'); + expect(valueEl.className).toContain('text-[#5A7B63]'); + expect(labelEl.className).toContain('text-[#5A7B63]'); + }); + + it('should apply moderate styles when intensity is between 4 and 6', () => { + form.get('scale')?.setValue(5); + fixture.detectChanges(); + + const indicator = getByTestId('intensity-indicator').nativeElement as HTMLElement; + const valueEl = getByTestId('intensity-value').nativeElement as HTMLElement; + const labelEl = getByTestId('intensity-label').nativeElement as HTMLElement; + + expect(indicator.className).toContain('border-[#C47B33]'); + expect(indicator.className).toContain('bg-[#F7EFE4]'); + expect(valueEl.className).toContain('text-[#C47B33]'); + expect(labelEl.className).toContain('text-[#C47B33]'); + }); + + it('should apply severe styles when intensity is between 7 and 10', () => { + form.get('scale')?.setValue(9); + fixture.detectChanges(); + + const indicator = getByTestId('intensity-indicator').nativeElement as HTMLElement; + const valueEl = getByTestId('intensity-value').nativeElement as HTMLElement; + const labelEl = getByTestId('intensity-label').nativeElement as HTMLElement; + + expect(indicator.className).toContain('border-[#B05A5A]'); + expect(indicator.className).toContain('bg-[#F8EAEA]'); + expect(valueEl.className).toContain('text-[#B05A5A]'); + expect(labelEl.className).toContain('text-[#B05A5A]'); + }); + + it('should calculate slider fill percentage as 0 when intensity is 1', () => { + form.get('scale')?.setValue(1); + fixture.detectChanges(); + + expect(component.sliderFillPercentage).toBe(0); + }); + + it('should calculate slider fill percentage as 100 when intensity is 10', () => { + form.get('scale')?.setValue(10); + fixture.detectChanges(); + + expect(component.sliderFillPercentage).toBe(100); + }); + + it('should apply neutral slider track style when scale is null', () => { + expect(component.sliderTrackStyle).toContain('#D9D4CE 0%'); + expect(component.sliderTrackStyle).toContain('#D9D4CE 100%'); + }); + + it('should apply mild slider track color when intensity is mild', () => { + form.get('scale')?.setValue(2); + fixture.detectChanges(); + + expect(component.sliderTrackStyle).toContain('#5A7B63'); + }); + + it('should apply moderate slider track color when intensity is moderate', () => { + form.get('scale')?.setValue(5); + fixture.detectChanges(); + + expect(component.sliderTrackStyle).toContain('#C47B33'); + }); + + it('should apply severe slider track color when intensity is severe', () => { + form.get('scale')?.setValue(9); + fixture.detectChanges(); + + expect(component.sliderTrackStyle).toContain('#B05A5A'); + }); + + it('should bind slider background style dynamically', () => { + form.get('scale')?.setValue(5); + fixture.detectChanges(); + + const slider = getByTestId('intensity-slider').nativeElement as HTMLInputElement; + expect(component.sliderTrackStyle).toContain('#C47B33'); + }); + + it('should bind slider color style dynamically', () => { + form.get('scale')?.setValue(9); + fixture.detectChanges(); + + const slider = getByTestId('intensity-slider').nativeElement as HTMLInputElement; + expect(component.intensityVisual.activeColor).toBe('#B05A5A'); + }); + + it('should show validation error when scale is invalid and touched', () => { + form.get('scale')?.markAsTouched(); + fixture.detectChanges(); + + expect(component.showError).toBeTruthy(); + expect(getByTestId('intensity-error')).toBeTruthy(); + }); + + it('should hide validation error when scale becomes valid', () => { + form.get('scale')?.markAsTouched(); + form.get('scale')?.setValue(4); + fixture.detectChanges(); + + expect(component.showError).toBeFalsy(); + expect(getByTestId('intensity-error')).toBeNull(); + }); + + it('should render min and max labels correctly', () => { + const minLabel = getByTestId('intensity-min-label').nativeElement as HTMLElement; + const maxLabel = getByTestId('intensity-max-label').nativeElement as HTMLElement; + + expect(minLabel.textContent?.trim()).toBe('1 - Nenhuma'); + expect(maxLabel.textContent?.trim()).toBe('10 - Severa'); + }); + + it('should not render form content when form input is not defined', () => { + const newFixture = TestBed.createComponent(CheckinStepIntensityComponent); + const newComponent = newFixture.componentInstance; + + expect(newComponent).toBeTruthy(); + + newFixture.detectChanges(); + + const step = newFixture.debugElement.query(By.css('[data-testid="intensity-step"]')); + expect(step).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/checkin/checkin.html b/frontend/src/app/features/checkin/checkin.html index ca3a667..cd918b2 100644 --- a/frontend/src/app/features/checkin/checkin.html +++ b/frontend/src/app/features/checkin/checkin.html @@ -56,7 +56,7 @@

Check-in

+ + } @else { +
+

Registrar consulta

+

{{ stepLabel() }}

+
+ +
+
+
+
+

+ Etapa {{ currentStepIndex() + 1 }} de {{ visibleSteps().length }} +

+
+ + @switch (currentStepId()) { + @case ('basics') { +
+
+
+ + + @if (showValidation() && basicsForm.controls.appointmentDate.invalid) { +

Informe a data.

+ } +
+
+ + + @if (showValidation() && basicsForm.controls.appointmentTime.invalid) { +

Informe a hora.

+ } +
+
+ +
+ + + @if (showValidation() && basicsForm.controls.location.invalid) { +

Informe o local.

+ } +
+ +
+ Tipo +
+ @for (option of appointmentTypes; track option.value) { + + } +
+ @if (showValidation() && basicsForm.controls.type.invalid) { +

Selecione o tipo.

+ } +
+ +
+ + +
+ +
+ + +
+
+ } + + @case ('performed') { +
+

+ Isso ajuda a saber se o compromisso já aconteceu ou ainda está pendente. +

+
+ Consulta realizada? +
+ + +
+
+ @if (draft().performed === false) { +

+ O registro ficará como agendado/pendente até você marcar como + realizada. +

+ } + @if (showValidation() && draft().performed === null) { +

Selecione Sim ou Não.

+ } +
+ } + + @case ('details-prompt') { +
+

+ Deseja registrar alguma informação dessa consulta? +

+

+ Você pode incluir conduta, medicamentos e orientações — tudo é opcional. +

+
+ + +
+ @if (showValidation() && draft().wantsFollowUpDetails === null) { +

Selecione Sim ou Não.

+ } +
+ } + + @case ('follow-up') { +
+ @if (isSupervisedDoseType()) { +
+
+ + Dose supervisionada mensal +
+

+ Registre a dose que você tomou neste atendimento. Isso ajuda a manter seu histórico + de tratamento em dia. +

+
+ } + +
+

Dose e medicamentos

+ + + +
+ + Houve mudança de medicamento? + +
+ + +
+ @if (showValidation() && draft().followUp.hadMedicationChange === null) { +

Informe se houve mudança de medicamento.

+ } +
+ + @if (draft().followUp.hadMedicationChange === false && draft().followUp.registerSupervisedDose) { +
+

+ Selecione o medicamento que você está tomando. Em breve você poderá cadastrar + seus remédios no perfil. +

+ + @if (patientMedications().length > 0) { +
+ @for (med of patientMedications(); track med.id) { + + } + +
+ } @else { +

+ Nenhum medicamento no perfil ainda — informe o nome abaixo. +

+ } + + @if ( + patientMedications().length === 0 || + draft().followUp.selectedMedicationId === 'other' + ) { +
+ + +
+ } + +
+ + +
+ + @if (showValidation() && !draft().followUp.otherMedicationName.trim()) { +

Selecione ou informe o medicamento da dose.

+ } +
+ } + + @if (draft().followUp.hadMedicationChange === true) { +
+

+ Descreva a mudança e registre o novo medicamento com a dose indicada pelo + profissional. +

+ +
+ + +
+ +
+ + + @if (showValidation() && !followUpForm.controls.newMedicationName.value?.trim()) { +

Informe o novo medicamento.

+ } +
+ +
+ + + @if (showValidation() && !followUpForm.controls.newDoseDescription.value?.trim()) { +

Informe a nova dose ou posologia.

+ } +
+
+ } + + @if ( + draft().followUp.hadMedicationChange === false && !draft().followUp.registerSupervisedDose + ) { +

+ Você pode marcar o registro da dose acima quando quiser anotar o atendimento mensal. +

+ } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ } + + @case ('summary') { +
+
+ + Resumo do registro +
+ +
+
+
Data e hora
+
+ {{ formatDate(draft().appointmentDate) }} · {{ draft().appointmentTime }} +
+
+
+
Local
+
{{ draft().location }}
+
+
+
Tipo
+
{{ typeLabel() }}
+
+ @if (draft().professional) { +
+
Profissional
+
{{ draft().professional }}
+
+ } + @if (draft().notes) { +
+
Observações
+
{{ draft().notes }}
+
+ } + @if (draft().followUp.hadMedicationChange === true) { +
+
Mudança de medicamento
+
+ {{ draft().followUp.newMedicationName }} — + {{ draft().followUp.newDoseDescription }} +
+ @if (draft().followUp.medicationChangeDescription) { +
+ {{ draft().followUp.medicationChangeDescription }} +
+ } +
+ } + @if ( + draft().followUp.hadMedicationChange === false && + draft().followUp.registerSupervisedDose && + draft().followUp.otherMedicationName + ) { +
+
Dose registrada
+
+ {{ selectedMedicationLabel() }} + @if (draft().followUp.supervisedDoseNotes) { + + {{ draft().followUp.supervisedDoseNotes }} + + } +
+
+ } +
+
Status
+
+ @if (draft().performed) { + Realizada + } @else { + Agendado / pendente + } +
+
+
+
+ } + } + +
+ + + @if (currentStepId() !== 'summary') { + + } @else { + + } +
+ } + diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts b/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts new file mode 100644 index 0000000..027f7a4 --- /dev/null +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts @@ -0,0 +1,42 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { RegisterAppointmentComponent } from './register-appointment'; + +describe('RegisterAppointmentComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [RegisterAppointmentComponent], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(RegisterAppointmentComponent); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('starts on basics step', () => { + expect(fixture.componentInstance.currentStepId()).toBe('basics'); + }); + + it('skips follow-up when appointment not performed', () => { + const component = fixture.componentInstance; + component.basicsForm.patchValue({ + appointmentDate: '2026-05-20', + appointmentTime: '10:00', + location: 'UBS', + type: 'exame', + professional: 'Dr. A', + }); + component.nextStep(); + component.onPerformedChange(false); + component.nextStep(); + expect(component.currentStepId()).toBe('summary'); + }); +}); diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.ts b/frontend/src/app/features/appointments/register-appointment/register-appointment.ts new file mode 100644 index 0000000..5f5a809 --- /dev/null +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.ts @@ -0,0 +1,335 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { LucideAngularModule, LucideCalendar, LucideCheck, LucidePill } from 'lucide-angular'; +import { + APPOINTMENT_TYPES, + EMPTY_APPOINTMENT_DRAFT, + type HealthAppointmentDraft, +} from '../models/health-appointment.models'; +import { HealthAppointmentService } from '../services/health-appointment.service'; +import { PatientMedicationService } from '../services/patient-medication.service'; + +type WizardStepId = 'basics' | 'performed' | 'details-prompt' | 'follow-up' | 'summary'; + +@Component({ + selector: 'app-register-appointment', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], + templateUrl: './register-appointment.html', +}) +export class RegisterAppointmentComponent { + private readonly fb = inject(FormBuilder); + private readonly router = inject(Router); + private readonly appointmentService = inject(HealthAppointmentService); + private readonly medicationService = inject(PatientMedicationService); + + readonly appointmentTypes = APPOINTMENT_TYPES; + readonly patientMedications = this.medicationService.medications; + readonly LucideCalendar = LucideCalendar; + readonly LucideCheck = LucideCheck; + readonly LucidePill = LucidePill; + + readonly currentStepIndex = signal(0); + readonly showValidation = signal(false); + readonly saved = signal(false); + readonly savedStatusLabel = signal(''); + + readonly draft = signal(structuredClone(EMPTY_APPOINTMENT_DRAFT)); + + readonly isSupervisedDoseType = computed(() => this.draft().type === 'dose_supervisionada'); + + readonly basicsForm = this.fb.group({ + appointmentDate: ['', Validators.required], + appointmentTime: ['', Validators.required], + location: ['', Validators.required], + type: ['', Validators.required], + professional: [''], + notes: [''], + }); + + readonly followUpForm = this.fb.group({ + conduct: [''], + medicationChangeDescription: [''], + newMedicationName: [''], + newDoseDescription: [''], + otherMedicationName: [''], + supervisedDoseNotes: [''], + nextAppointmentDate: [''], + guidanceReceived: [''], + }); + + readonly visibleSteps = computed(() => { + const performed = this.draft().performed; + const wantsDetails = this.draft().wantsFollowUpDetails; + const steps: WizardStepId[] = ['basics', 'performed']; + if (performed === true) { + steps.push('details-prompt'); + if (wantsDetails === true) { + steps.push('follow-up'); + } + } + steps.push('summary'); + return steps; + }); + + readonly currentStepId = computed( + () => this.visibleSteps()[this.currentStepIndex()] ?? 'basics' + ); + + readonly progressPercentage = computed(() => { + const total = this.visibleSteps().length; + if (total <= 1) return 100; + return ((this.currentStepIndex() + 1) / total) * 100; + }); + + readonly stepLabel = computed(() => { + switch (this.currentStepId()) { + case 'basics': + return 'Dados do compromisso'; + case 'performed': + return 'Status do atendimento'; + case 'details-prompt': + return 'Informações da consulta'; + case 'follow-up': + return this.isSupervisedDoseType() + ? 'Dose e medicamentos' + : 'Detalhes do atendimento'; + case 'summary': + return 'Revisão'; + default: + return ''; + } + }); + + readonly typeLabel = computed(() => { + const type = this.draft().type; + return APPOINTMENT_TYPES.find((t) => t.value === type)?.label ?? ''; + }); + + readonly selectedMedicationLabel = computed(() => { + const fu = this.draft().followUp; + if (fu.selectedMedicationId && fu.selectedMedicationId !== 'other') { + return this.medicationService.findById(fu.selectedMedicationId)?.name ?? ''; + } + return fu.otherMedicationName; + }); + + onPerformedChange(value: boolean): void { + this.draft.update((d) => ({ + ...d, + performed: value, + wantsFollowUpDetails: value ? d.wantsFollowUpDetails : null, + })); + this.showValidation.set(false); + } + + onWantsDetailsChange(value: boolean): void { + this.draft.update((d) => { + const next = { ...d, wantsFollowUpDetails: value }; + if (value && d.type === 'dose_supervisionada') { + next.followUp = { + ...d.followUp, + registerSupervisedDose: true, + }; + } + return next; + }); + this.showValidation.set(false); + } + + onRegisterDoseChange(checked: boolean): void { + this.draft.update((d) => ({ + ...d, + followUp: { ...d.followUp, registerSupervisedDose: checked }, + })); + this.showValidation.set(false); + } + + onHadMedicationChange(value: boolean): void { + this.draft.update((d) => ({ + ...d, + followUp: { + ...d.followUp, + hadMedicationChange: value, + selectedMedicationId: value ? '' : d.followUp.selectedMedicationId, + otherMedicationName: value ? '' : d.followUp.otherMedicationName, + medicationChangeDescription: value ? d.followUp.medicationChangeDescription : '', + newMedicationName: value ? d.followUp.newMedicationName : '', + newDoseDescription: value ? d.followUp.newDoseDescription : '', + }, + })); + this.followUpForm.patchValue({ + medicationChangeDescription: value ? this.draft().followUp.medicationChangeDescription : '', + newMedicationName: value ? this.draft().followUp.newMedicationName : '', + newDoseDescription: value ? this.draft().followUp.newDoseDescription : '', + otherMedicationName: value ? '' : this.draft().followUp.otherMedicationName, + }); + this.showValidation.set(false); + } + + onMedicationSelect(medicationId: string): void { + const med = this.medicationService.findById(medicationId); + this.draft.update((d) => ({ + ...d, + followUp: { + ...d.followUp, + selectedMedicationId: medicationId, + otherMedicationName: med?.name ?? '', + }, + })); + this.followUpForm.patchValue({ otherMedicationName: med?.name ?? '' }); + this.showValidation.set(false); + } + + onMedicationOtherSelect(): void { + this.draft.update((d) => ({ + ...d, + followUp: { + ...d.followUp, + selectedMedicationId: 'other', + otherMedicationName: '', + }, + })); + this.followUpForm.patchValue({ otherMedicationName: '' }); + this.showValidation.set(false); + } + + onOtherMedicationInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.draft.update((d) => ({ + ...d, + followUp: { ...d.followUp, otherMedicationName: value }, + })); + } + + private syncFollowUpFromForm(): void { + const raw = this.followUpForm.getRawValue(); + this.draft.update((d) => ({ + ...d, + followUp: { + ...d.followUp, + conduct: raw.conduct ?? '', + medicationChangeDescription: raw.medicationChangeDescription ?? '', + newMedicationName: raw.newMedicationName ?? '', + newDoseDescription: raw.newDoseDescription ?? '', + otherMedicationName: raw.otherMedicationName ?? d.followUp.otherMedicationName, + supervisedDoseNotes: raw.supervisedDoseNotes ?? '', + nextAppointmentDate: raw.nextAppointmentDate ?? '', + guidanceReceived: raw.guidanceReceived ?? '', + }, + })); + } + + private validateFollowUpStep(): boolean { + this.syncFollowUpFromForm(); + const fu = this.draft().followUp; + + if (fu.hadMedicationChange === null) { + this.showValidation.set(true); + return false; + } + + if (fu.hadMedicationChange === true) { + const name = fu.newMedicationName.trim(); + const dose = fu.newDoseDescription.trim(); + if (!name || !dose) { + this.showValidation.set(true); + return false; + } + return true; + } + + if (fu.registerSupervisedDose) { + const medName = fu.otherMedicationName.trim(); + const hasSelection = + (fu.selectedMedicationId && fu.selectedMedicationId !== 'other') || medName.length > 0; + if (!hasSelection) { + this.showValidation.set(true); + return false; + } + } + + return true; + } + + nextStep(): void { + const stepId = this.currentStepId(); + + if (stepId === 'basics' && this.basicsForm.invalid) { + this.basicsForm.markAllAsTouched(); + this.showValidation.set(true); + return; + } + + if (stepId === 'basics') { + const raw = this.basicsForm.getRawValue(); + this.draft.update((d) => { + const type = (raw.type ?? '') as HealthAppointmentDraft['type']; + const next: HealthAppointmentDraft = { + ...d, + appointmentDate: raw.appointmentDate ?? '', + appointmentTime: raw.appointmentTime ?? '', + location: raw.location ?? '', + type, + professional: raw.professional ?? '', + notes: raw.notes ?? '', + }; + if (type === 'dose_supervisionada' && d.wantsFollowUpDetails === true) { + next.followUp = { ...d.followUp, registerSupervisedDose: true }; + } + return next; + }); + } + + if (stepId === 'performed' && this.draft().performed === null) { + this.showValidation.set(true); + return; + } + + if (stepId === 'details-prompt' && this.draft().wantsFollowUpDetails === null) { + this.showValidation.set(true); + return; + } + + if (stepId === 'follow-up' && !this.validateFollowUpStep()) { + return; + } + + this.showValidation.set(false); + const maxIndex = this.visibleSteps().length - 1; + if (this.currentStepIndex() < maxIndex) { + this.currentStepIndex.update((i) => i + 1); + } + } + + prevStep(): void { + if (this.currentStepIndex() > 0) { + this.showValidation.set(false); + this.currentStepIndex.update((i) => i - 1); + } + } + + submit(): void { + this.syncFollowUpFromForm(); + const record = this.appointmentService.saveFromDraft(this.draft()); + this.savedStatusLabel.set(record.status === 'scheduled' ? 'Agendado' : 'Realizado'); + this.saved.set(true); + } + + goHome(): void { + void this.router.navigate(['/home']); + } + + formatDate(isoDate: string): string { + if (!isoDate) return ''; + const [y, m, d] = isoDate.split('-').map(Number); + if (!y || !m || !d) return isoDate; + return new Date(y, m - 1, d).toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric', + }); + } +} diff --git a/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts b/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts new file mode 100644 index 0000000..b8c00b5 --- /dev/null +++ b/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts @@ -0,0 +1,87 @@ +import { TestBed } from '@angular/core/testing'; +import { + EMPTY_APPOINTMENT_DRAFT, + EMPTY_FOLLOW_UP_DRAFT, + type HealthAppointmentDraft, +} from '../models/health-appointment.models'; +import { HealthAppointmentService } from './health-appointment.service'; + +describe('HealthAppointmentService', () => { + let service: HealthAppointmentService; + + const baseDraft: HealthAppointmentDraft = { + ...EMPTY_APPOINTMENT_DRAFT, + appointmentDate: '2026-05-20', + appointmentTime: '14:30', + location: 'UBS Centro', + type: 'consulta', + professional: 'Dr. Silva', + notes: 'Trazer exames', + performed: false, + wantsFollowUpDetails: null, + }; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({}); + service = TestBed.inject(HealthAppointmentService); + }); + + it('allows saving without professional', () => { + const record = service.saveFromDraft({ ...baseDraft, professional: '' }); + expect(record.professional).toBeUndefined(); + }); + + it('marks appointment as scheduled when not performed', () => { + const record = service.saveFromDraft(baseDraft); + expect(record.status).toBe('scheduled'); + expect(record.performed).toBe(false); + expect(service.appointments()).toHaveLength(1); + }); + + it('marks appointment as completed when performed', () => { + const record = service.saveFromDraft({ + ...baseDraft, + performed: true, + wantsFollowUpDetails: false, + }); + expect(record.status).toBe('completed'); + expect(record.performed).toBe(true); + }); + + it('stores supervised dose when medication unchanged', () => { + const record = service.saveFromDraft({ + ...baseDraft, + type: 'dose_supervisionada', + performed: true, + wantsFollowUpDetails: true, + followUp: { + ...EMPTY_FOLLOW_UP_DRAFT, + hadMedicationChange: false, + registerSupervisedDose: true, + selectedMedicationId: 'med-1', + otherMedicationName: 'Rifampicina', + supervisedDoseNotes: 'Dose mensal', + }, + }); + expect(record.followUp?.supervisedDose?.medicationName).toBe('Rifampicina'); + expect(record.followUp?.hadMedicationChange).toBe(false); + }); + + it('stores medication change with new dose', () => { + const record = service.saveFromDraft({ + ...baseDraft, + performed: true, + wantsFollowUpDetails: true, + followUp: { + ...EMPTY_FOLLOW_UP_DRAFT, + hadMedicationChange: true, + newMedicationName: 'Clofazimina', + newDoseDescription: '1 cápsula ao dia', + medicationChangeDescription: 'Ajuste do esquema', + }, + }); + expect(record.followUp?.medicationChange?.newMedicationName).toBe('Clofazimina'); + expect(record.followUp?.medicationChange?.newDoseDescription).toBe('1 cápsula ao dia'); + }); +}); diff --git a/frontend/src/app/features/appointments/services/health-appointment.service.ts b/frontend/src/app/features/appointments/services/health-appointment.service.ts new file mode 100644 index 0000000..5779d7e --- /dev/null +++ b/frontend/src/app/features/appointments/services/health-appointment.service.ts @@ -0,0 +1,104 @@ +import { Injectable, signal } from '@angular/core'; +import type { + AppointmentFollowUp, + AppointmentFollowUpDraft, + HealthAppointment, + HealthAppointmentDraft, +} from '../models/health-appointment.models'; + +const STORAGE_KEY = 'pequi.health_appointments'; + +@Injectable({ providedIn: 'root' }) +export class HealthAppointmentService { + private readonly appointmentsSignal = signal(this.loadFromStorage()); + + readonly appointments = this.appointmentsSignal.asReadonly(); + + saveFromDraft(draft: HealthAppointmentDraft): HealthAppointment { + const performed = draft.performed === true; + const record: HealthAppointment = { + id: crypto.randomUUID(), + appointmentDate: draft.appointmentDate, + appointmentTime: draft.appointmentTime, + location: draft.location.trim(), + type: draft.type as HealthAppointment['type'], + professional: draft.professional.trim() || undefined, + notes: draft.notes.trim() || undefined, + performed, + status: performed ? 'completed' : 'scheduled', + wantsFollowUpDetails: performed && draft.wantsFollowUpDetails === true, + followUp: performed && draft.wantsFollowUpDetails ? this.buildFollowUp(draft.followUp) : undefined, + createdAt: new Date().toISOString(), + }; + + const next = [record, ...this.appointmentsSignal()]; + this.appointmentsSignal.set(next); + this.persist(next); + return record; + } + + private buildFollowUp(raw: AppointmentFollowUpDraft): AppointmentFollowUp | undefined { + const result: AppointmentFollowUp = { + conduct: raw.conduct?.trim() || undefined, + guidanceReceived: raw.guidanceReceived?.trim() || undefined, + nextAppointmentDate: raw.nextAppointmentDate || undefined, + }; + + if (raw.hadMedicationChange === true) { + result.hadMedicationChange = true; + const newName = raw.newMedicationName?.trim(); + const newDose = raw.newDoseDescription?.trim(); + if (newName && newDose) { + result.medicationChange = { + description: raw.medicationChangeDescription?.trim() || undefined, + newMedicationName: newName, + newDoseDescription: newDose, + }; + } + } else if (raw.hadMedicationChange === false && raw.registerSupervisedDose) { + result.hadMedicationChange = false; + const medName = raw.otherMedicationName?.trim(); + if (medName) { + result.supervisedDose = { + medicationId: + raw.selectedMedicationId && raw.selectedMedicationId !== 'other' + ? raw.selectedMedicationId + : undefined, + medicationName: medName, + notes: raw.supervisedDoseNotes?.trim() || undefined, + }; + } + } else if (raw.hadMedicationChange === false) { + result.hadMedicationChange = false; + } + + const hasValue = + result.conduct !== undefined || + result.guidanceReceived !== undefined || + result.nextAppointmentDate !== undefined || + result.hadMedicationChange !== undefined || + result.medicationChange !== undefined || + result.supervisedDose !== undefined; + + return hasValue ? result : undefined; + } + + private loadFromStorage(): HealthAppointment[] { + if (typeof localStorage === 'undefined') { + return []; + } + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as HealthAppointment[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private persist(items: HealthAppointment[]): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } +} diff --git a/frontend/src/app/features/appointments/services/patient-medication.service.ts b/frontend/src/app/features/appointments/services/patient-medication.service.ts new file mode 100644 index 0000000..0dc6023 --- /dev/null +++ b/frontend/src/app/features/appointments/services/patient-medication.service.ts @@ -0,0 +1,34 @@ +import { Injectable, signal } from '@angular/core'; +import type { PatientMedication } from '../models/patient-medication.models'; + +const STORAGE_KEY = 'pequi.patient_medications'; + +/** + * Medicamentos do perfil do paciente. Hoje lê do localStorage; + * quando o perfil tiver CRUD, este serviço passa a ser a única fonte. + */ +@Injectable({ providedIn: 'root' }) +export class PatientMedicationService { + private readonly medicationsSignal = signal(this.loadFromStorage()); + + readonly medications = this.medicationsSignal.asReadonly(); + readonly hasMedications = () => this.medicationsSignal().length > 0; + + findById(id: string): PatientMedication | undefined { + return this.medicationsSignal().find((m) => m.id === id); + } + + private loadFromStorage(): PatientMedication[] { + if (typeof localStorage === 'undefined') { + return []; + } + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as PatientMedication[]; + return Array.isArray(parsed) ? parsed.filter((m) => m?.id && m?.name) : []; + } catch { + return []; + } + } +} diff --git a/frontend/src/app/features/home/home.css b/frontend/src/app/features/home/home.css index d609b26..bd16ed2 100644 --- a/frontend/src/app/features/home/home.css +++ b/frontend/src/app/features/home/home.css @@ -146,6 +146,11 @@ color: #4caf50; } +.purple-icon { + background-color: #ede7f6; + color: #5b48d9; +} + .card-text { display: flex; flex-direction: column; diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index 6823873..cd56f56 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -1,6 +1,6 @@ import { Component, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { LucideAngularModule, ImagePlus, CirclePlus, Calendar } from 'lucide-angular'; +import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope } from 'lucide-angular'; import { Router } from '@angular/router'; interface QuickAction { @@ -18,22 +18,6 @@ interface CalendarWeek { dots: number[]; } -interface CalendarWeek { - dateObj: Date; - dayName: string; - dayNumber: number; - dots: number[]; -} - -interface Article { - tag: string; - title: string; - description: string; - imageUrl: string; - actionText: string; - actionUrl: string; -} - interface Article { tag: string; title: string; @@ -55,6 +39,7 @@ export class HomeComponent implements OnInit { readonly ImagePlus = ImagePlus; readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; + readonly Stethoscope = Stethoscope; currentMonthYear: string = ''; calendarWeek: CalendarWeek[] = []; @@ -68,6 +53,13 @@ export class HomeComponent implements OnInit { colorClass: 'blue-icon', path: '/checkin', }, + { + title: 'Registrar consulta', + description: 'Consultas, exames e retornos', + icon: this.Stethoscope, + colorClass: 'purple-icon', + path: '/appointments/register', + }, { title: 'Registro de Fotos', description: 'Acompanhe mudanças na pele', @@ -88,7 +80,8 @@ export class HomeComponent implements OnInit { }; executeAction(path: string) { - this.router.navigate([path]); + if (!path) return; + void this.router.navigate([path]); } ngOnInit(): void { diff --git a/frontend/src/app/layout/app-shell-component/app-shell-component.ts b/frontend/src/app/layout/app-shell-component/app-shell-component.ts index 878a47c..6146589 100644 --- a/frontend/src/app/layout/app-shell-component/app-shell-component.ts +++ b/frontend/src/app/layout/app-shell-component/app-shell-component.ts @@ -50,6 +50,9 @@ export class AppShellComponent { } else if (onNotifications) { this.headerLayout.set('withBack'); this.headerPageTitle.set('Notificações'); + } else if (path.includes('/appointments/register')) { + this.headerLayout.set('withBack'); + this.headerPageTitle.set('Registrar consulta'); } else { this.headerLayout.set('default'); } From a6232a3e4d75218fe6afd0f83e0c7fc9e5bf5c5b Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Wed, 20 May 2026 22:26:57 -0300 Subject: [PATCH 05/69] chore(ci): enhance CI/CD workflows and update dependencies - Added new CI workflow for managing linting, testing, and security checks. - Updated build workflow to include Docker image validation and improved environment setup. - Integrated PostgreSQL and Redis services for testing. - Enhanced Dockerfile for better dependency management and added health checks. - Updated Python dependencies in pyproject.toml to include coverage and other testing tools. Co-authored-by: Rafael Luciano Co-authored-by: Lucas Heron Co-authored-by: Sarah Domingos Co-authored-by: Leila Biggi --- .github/workflows/build.yml | 106 ++++++++++++++++++++++++++-- .github/workflows/ci.yml | 31 +++++++++ .github/workflows/lint.yml | 53 ++++++++++++++ .github/workflows/security.yml | 96 ++++++++++++++++++++++++++ .github/workflows/tests.yml | 122 +++++++++++++++++++++++++++++++++ backend/Dockerfile | 10 ++- backend/pyproject.toml | 10 ++- backend/uv.lock | 114 +++++++++++++++++++++++++++--- 8 files changed, 518 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14216ee..33bc25a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,19 +1,111 @@ name: Build + +# Responsabilidade exclusiva: cobertura XML para SonarQube + build e validação da imagem Docker +# Lint e testes rápidos são responsabilidade do ci.yml on: push: branches: - main + - development + pull_request: - types: [opened, synchronize, reopened] + types: + - opened + - synchronize + - reopened + +env: + PYTHON_VERSION: "3.12" + jobs: - sonarqube: - name: SonarQube + build: + name: Build + SonarQube runs-on: ubuntu-latest + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_USER: pequi + POSTGRES_PASSWORD: pequi + POSTGRES_DB: pequi_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U pequi" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup UV + uses: astral-sh/setup-uv@v3 + + - name: Setup Python + uses: actions/setup-python@v5 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + python-version: ${{ env.PYTHON_VERSION }} + + - name: Cache UV virtualenv + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + .venv + key: build-${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + restore-keys: build-${{ runner.os }}-uv- + + - name: Install dependencies + run: uv sync --frozen --extra dev + + - name: Create env + env: + ENV_CONTENT: | + ENV=test + DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + REDIS_URL=redis://localhost:6379/0 + JWT_SECRET=test-secret + SENTRY_DSN= + STORAGE_ENDPOINT=http://localhost:9000 + STORAGE_ACCESS_KEY=minioadmin + STORAGE_SECRET_KEY=minioadmin + STORAGE_BUCKET_IMAGES=pequi-images + run: printf '%s' "$ENV_CONTENT" > .env + + - name: Run migrations + run: uv run alembic upgrade head + + # Usa scripts/run_tests.sh conforme mandato do AGENTS.md: + # garante unsetting de credentials reais, TZ=UTC e paridade com CI local + - name: Run tests with coverage + run: | + chmod +x scripts/run_tests.sh + scripts/run_tests.sh \ + --cov=src/pequi \ + --cov-report=xml \ + --cov-report=term-missing \ + --cov-fail-under=80 + + - name: Build Docker image + run: docker build -t pequi-backend:ci backend/ + - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6.0.0 + uses: SonarSource/sonarqube-scan-action@v6 env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e69de29..727a2d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + branches: + - development + - main + push: + branches: + - development + - main + workflow_dispatch: + +# Cancela runs anteriores do mesmo branch/PR ao receber novo push +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + uses: ./.github/workflows/lint.yml + + tests: + needs: lint + uses: ./.github/workflows/tests.yml + + # secrets: inherit repassa GITHUB_TOKEN para Gitleaks e demais scanners + security: + needs: lint + uses: ./.github/workflows/security.yml + secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e69de29..9a6d6c5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -0,0 +1,53 @@ +name: Lint + +# Só é chamado pelo ci.yml — sem triggers próprios para evitar double execution +on: + workflow_call: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup UV + uses: astral-sh/setup-uv@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Cache do virtualenv baseado no uv.lock para runs mais rápidas + - name: Cache UV virtualenv + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + .venv + key: lint-${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + restore-keys: lint-${{ runner.os }}-uv- + + - name: Install dependencies + run: uv sync --frozen --extra dev + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format + run: uv run ruff format --check . + + - name: Validate imports + run: uv run python -m compileall src + + # Verifica existência de pelo menos uma migration — mais significativo que só checar a pasta + - name: Validate Alembic migrations + run: | + migration_count=$(find alembic/versions -maxdepth 1 -name "*.py" ! -name "__init__*" | wc -l) + if [ "$migration_count" -eq 0 ]; then + echo "::error::Nenhum arquivo de migration encontrado em alembic/versions/" + exit 1 + fi + echo "Encontradas $migration_count migration(s) — OK" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e69de29..b18feec 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -0,0 +1,96 @@ +name: Security + +# Só é chamado pelo ci.yml — sem triggers próprios para evitar double execution +on: + workflow_call: + +jobs: + security: + runs-on: ubuntu-latest + + permissions: + security-events: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # fetch-depth completo necessário para Gitleaks inspecionar histórico + fetch-depth: 0 + + - name: Setup UV + uses: astral-sh/setup-uv@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --frozen + + # Versões pinadas para builds reproduzíveis em sistema de saúde crítico + - name: Install security tools + run: | + uv tool install "pip-audit==2.7.3" + uv tool install "bandit==1.8.3" + + - name: Run pip-audit (dependency CVEs) + run: pip-audit --strict + + # Bandit: gera resultado tanto em tabela (para log legível) quanto SARIF (para Security tab) + - name: Run Bandit (table) + run: bandit -r src -ll + + - name: Run Bandit (SARIF upload) + run: bandit -r src -ll -f sarif -o bandit.sarif + continue-on-error: true + + - name: Upload Bandit SARIF + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: bandit.sarif + category: bandit + + # Lint do Dockerfile — detecta antipatterns antes do build + - name: Lint Dockerfile + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: backend/Dockerfile + failure-threshold: warning + + - name: Scan secrets with Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Docker image for scanning + run: docker build -t pequi-security-check backend/ + + # Trivy: CRITICAL e HIGH com exit-code 1 bloqueiam o pipeline + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@0.24.0 + with: + image-ref: pequi-security-check + format: table + exit-code: 1 + ignore-unfixed: true + vuln-type: "os,library" + severity: "CRITICAL,HIGH" + + # SBOM para rastreabilidade de componentes (requisito LGPD/auditoria clínica) + - name: Generate SBOM + uses: anchore/sbom-action@v0 + with: + image: pequi-security-check + format: cyclonedx-json + output-file: sbom.cyclonedx.json + + - name: Upload SBOM artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: sbom + path: sbom.cyclonedx.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e69de29..1c0240e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -0,0 +1,122 @@ +name: Tests + +# Só é chamado pelo ci.yml — sem triggers próprios para evitar double execution +on: + workflow_call: + +env: + TZ: UTC + LANG: C.UTF-8 + PYTHONUNBUFFERED: 1 + +jobs: + tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_USER: pequi + POSTGRES_PASSWORD: pequi + POSTGRES_DB: pequi_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U pequi" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup UV + uses: astral-sh/setup-uv@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Cache UV virtualenv + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + .venv + key: tests-${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + restore-keys: tests-${{ runner.os }}-uv- + + - name: Install dependencies + run: uv sync --frozen --extra dev + + # MinIO não suporta service container direto no GH Actions (precisa de CMD server /data) + # Iniciamos via docker run na etapa de setup + - name: Start MinIO + run: | + docker run -d \ + --name minio \ + -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:RELEASE.2024-11-07T00-52-20Z \ + server /data + echo "Aguardando MinIO..." + timeout 60 bash -c \ + 'until curl -sf http://localhost:9000/minio/health/live; do sleep 2; done' + echo "MinIO pronto" + + # Escrita via variável de ambiente para evitar o bug de leading-spaces do heredoc YAML + - name: Create test env + env: + ENV_CONTENT: | + ENV=test + DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + REDIS_URL=redis://localhost:6379/0 + SENTRY_DSN= + JWT_SECRET=test-secret + STORAGE_ENDPOINT=http://localhost:9000 + STORAGE_ACCESS_KEY=minioadmin + STORAGE_SECRET_KEY=minioadmin + STORAGE_BUCKET_IMAGES=pequi-images + run: printf '%s' "$ENV_CONTENT" > .env + + - name: Run migrations + run: uv run alembic upgrade head + + - name: Run tests with coverage + run: | + chmod +x scripts/run_tests.sh + scripts/run_tests.sh \ + --cov=src/pequi \ + --cov-report=xml \ + --cov-report=term-missing \ + --cov-fail-under=80 + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: | + coverage.xml + .coverage + + - name: Upload pytest cache + uses: actions/upload-artifact@v4 + if: always() + with: + name: pytest-artifacts + path: .pytest_cache diff --git a/backend/Dockerfile b/backend/Dockerfile index 32c70d2..a2000f1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,10 +8,13 @@ WORKDIR /app RUN pip install uv COPY pyproject.toml . +COPY uv.lock . COPY README.md . +COPY alembic.ini . +COPY alembic/ ./alembic/ COPY src/ ./src/ -RUN uv sync --no-dev --compile-bytecode +RUN uv sync --frozen --no-dev --compile-bytecode # ── Runtime ─────────────────────────────────────────────────────────────────── FROM python:3.12-slim AS runtime @@ -23,6 +26,8 @@ RUN groupadd --gid 1001 pequi \ COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/src /app/src +COPY --from=builder /app/alembic /app/alembic +COPY --from=builder /app/alembic.ini /app/alembic.ini ENV PATH="/app/.venv/bin:$PATH" \ PYTHONPATH="/app/src" \ @@ -33,4 +38,7 @@ USER pequi EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" || exit 1 + CMD ["uvicorn", "pequi.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 333c3f0..d4a01a9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,16 +26,14 @@ dependencies = [ "aiobotocore>=2.15.0", "anthropic>=0.40.0", "httpx>=0.28.0", - "pytest>=9.0.3", - "pytest-asyncio>=1.3.0", - "pytest-xdist>=3.8.0", ] [project.optional-dependencies] dev = [ - "pytest>=8.3.0", - "pytest-asyncio>=0.24.0", - "pytest-xdist>=3.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", + "pytest-xdist>=3.8.0", + "pytest-cov>=5.0", "pytest-mock>=3.14.0", "ruff>=0.8.0", "factory-boy>=3.3.0", diff --git a/backend/uv.lock b/backend/uv.lock index 8484fa4..9ab7ff8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -434,6 +434,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -1331,7 +1415,7 @@ bcrypt = [ [[package]] name = "pequi" -version = "0.1.0" +version = "0.0.1" source = { editable = "." } dependencies = [ { name = "aiobotocore" }, @@ -1344,9 +1428,6 @@ dependencies = [ { name = "passlib", extra = ["bcrypt"] }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-xdist" }, { name = "python-jose", extra = ["cryptography"] }, { name = "redis", extra = ["hiredis"] }, { name = "sentry-sdk", extra = ["fastapi"] }, @@ -1361,6 +1442,7 @@ dev = [ { name = "factory-boy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -1379,13 +1461,11 @@ requires-dist = [ { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, - { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, - { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "redis", extras = ["hiredis"], specifier = ">=5.2.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, @@ -1687,6 +1767,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1" From d73636e0a0fd8bd47ed7835c5521a606436c1217 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Wed, 20 May 2026 22:36:05 -0300 Subject: [PATCH 06/69] chore(ci): standardize workflows with backend directory setup --- .github/workflows/build.yml | 10 +++++++--- .github/workflows/ci.yml | 4 ++++ .github/workflows/lint.yml | 8 ++++++-- .github/workflows/security.yml | 15 ++++++++++----- .github/workflows/tests.yml | 14 +++++++++----- 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33bc25a..3906da5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,10 @@ on: env: PYTHON_VERSION: "3.12" +defaults: + run: + working-directory: backend + jobs: build: name: Build + SonarQube @@ -66,8 +70,8 @@ jobs: with: path: | ~/.cache/uv - .venv - key: build-${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + backend/.venv + key: build-${{ runner.os }}-uv-${{ hashFiles('backend/uv.lock') }} restore-keys: build-${{ runner.os }}-uv- - name: Install dependencies @@ -102,7 +106,7 @@ jobs: --cov-fail-under=80 - name: Build Docker image - run: docker build -t pequi-backend:ci backend/ + run: docker build -t pequi-backend:ci . - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 727a2d5..209fcf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,11 @@ jobs: uses: ./.github/workflows/tests.yml # secrets: inherit repassa GITHUB_TOKEN para Gitleaks e demais scanners + # permissions: o caller deve conceder o que o reusable workflow pede (security-events: write) security: needs: lint uses: ./.github/workflows/security.yml secrets: inherit + permissions: + contents: read + security-events: write diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9a6d6c5..a5d1064 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,10 @@ name: Lint on: workflow_call: +defaults: + run: + working-directory: backend + jobs: lint: runs-on: ubuntu-latest @@ -26,8 +30,8 @@ jobs: with: path: | ~/.cache/uv - .venv - key: lint-${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + backend/.venv + key: lint-${{ runner.os }}-uv-${{ hashFiles('backend/uv.lock') }} restore-keys: lint-${{ runner.os }}-uv- - name: Install dependencies diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b18feec..0aa5798 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,13 +4,18 @@ name: Security on: workflow_call: +defaults: + run: + working-directory: backend + jobs: security: runs-on: ubuntu-latest + # Permissões efetivas vêm do job caller em ci.yml (reusable workflows não elevam sozinhos) permissions: - security-events: write contents: read + security-events: write steps: - name: Checkout @@ -51,7 +56,7 @@ jobs: uses: github/codeql-action/upload-sarif@v3 if: always() with: - sarif_file: bandit.sarif + sarif_file: backend/bandit.sarif category: bandit # Lint do Dockerfile — detecta antipatterns antes do build @@ -67,7 +72,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image for scanning - run: docker build -t pequi-security-check backend/ + run: docker build -t pequi-security-check . # Trivy: CRITICAL e HIGH com exit-code 1 bloqueiam o pipeline - name: Run Trivy vulnerability scanner @@ -86,11 +91,11 @@ jobs: with: image: pequi-security-check format: cyclonedx-json - output-file: sbom.cyclonedx.json + output-file: backend/sbom.cyclonedx.json - name: Upload SBOM artifact uses: actions/upload-artifact@v4 if: always() with: name: sbom - path: sbom.cyclonedx.json + path: backend/sbom.cyclonedx.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c0240e..4551e95 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,10 @@ env: LANG: C.UTF-8 PYTHONUNBUFFERED: 1 +defaults: + run: + working-directory: backend + jobs: tests: runs-on: ubuntu-latest @@ -55,8 +59,8 @@ jobs: with: path: | ~/.cache/uv - .venv - key: tests-${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + backend/.venv + key: tests-${{ runner.os }}-uv-${{ hashFiles('backend/uv.lock') }} restore-keys: tests-${{ runner.os }}-uv- - name: Install dependencies @@ -111,12 +115,12 @@ jobs: with: name: coverage-report path: | - coverage.xml - .coverage + backend/coverage.xml + backend/.coverage - name: Upload pytest cache uses: actions/upload-artifact@v4 if: always() with: name: pytest-artifacts - path: .pytest_cache + path: backend/.pytest_cache From 93ee643026ecd2d0109e98d426e8813af452b6ae Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Wed, 20 May 2026 22:41:10 -0300 Subject: [PATCH 07/69] chore(ci): update environment variables for development in workflows --- .github/workflows/build.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3906da5..d00f3e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,10 +80,10 @@ jobs: - name: Create env env: ENV_CONTENT: | - ENV=test + ENV=development + SECRET_KEY=test-secret-do-not-use-in-production DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test REDIS_URL=redis://localhost:6379/0 - JWT_SECRET=test-secret SENTRY_DSN= STORAGE_ENDPOINT=http://localhost:9000 STORAGE_ACCESS_KEY=minioadmin diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4551e95..1332ffc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -86,11 +86,11 @@ jobs: - name: Create test env env: ENV_CONTENT: | - ENV=test + ENV=development + SECRET_KEY=test-secret-do-not-use-in-production DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test REDIS_URL=redis://localhost:6379/0 SENTRY_DSN= - JWT_SECRET=test-secret STORAGE_ENDPOINT=http://localhost:9000 STORAGE_ACCESS_KEY=minioadmin STORAGE_SECRET_KEY=minioadmin From ff8d6d4ee31ca5ba215b42d2fbcb148b66721d08 Mon Sep 17 00:00:00 2001 From: Leila Biggi Date: Thu, 21 May 2026 12:46:23 -0300 Subject: [PATCH 08/69] fix: no more hidden steps --- .../models/health-appointment.models.ts | 2 - .../register-appointment.html | 59 +++----------- .../register-appointment.spec.ts | 7 +- .../register-appointment.ts | 81 +++++-------------- .../health-appointment.service.spec.ts | 6 +- .../services/health-appointment.service.ts | 5 +- 6 files changed, 45 insertions(+), 115 deletions(-) diff --git a/frontend/src/app/features/appointments/models/health-appointment.models.ts b/frontend/src/app/features/appointments/models/health-appointment.models.ts index de400c8..c5d2bdd 100644 --- a/frontend/src/app/features/appointments/models/health-appointment.models.ts +++ b/frontend/src/app/features/appointments/models/health-appointment.models.ts @@ -71,7 +71,6 @@ export interface HealthAppointmentDraft { professional: string; notes: string; performed: boolean | null; - wantsFollowUpDetails: boolean | null; followUp: AppointmentFollowUpDraft; } @@ -97,6 +96,5 @@ export const EMPTY_APPOINTMENT_DRAFT: HealthAppointmentDraft = { professional: '', notes: '', performed: null, - wantsFollowUpDetails: null, followUp: { ...EMPTY_FOLLOW_UP_DRAFT }, }; diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.html b/frontend/src/app/features/appointments/register-appointment/register-appointment.html index dc1081d..92472e9 100644 --- a/frontend/src/app/features/appointments/register-appointment/register-appointment.html +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.html @@ -38,7 +38,7 @@

Registrar consulta

>

- Etapa {{ currentStepIndex() + 1 }} de {{ visibleSteps().length }} + Etapa {{ currentStepIndex() + 1 }} de {{ wizardStepCount }}

@@ -205,53 +205,16 @@

Registrar consulta

@if (showValidation() && draft().performed === null) {

Selecione Sim ou Não.

} - - } - @case ('details-prompt') { -
-

- Deseja registrar alguma informação dessa consulta? -

-

- Você pode incluir conduta, medicamentos e orientações — tudo é opcional. -

-
- - -
- @if (showValidation() && draft().wantsFollowUpDetails === null) { -

Selecione Sim ou Não.

- } -
- } - - @case ('follow-up') { - @if (isSupervisedDoseType()) {
Dose e medicamentos class="w-full resize-none rounded-xl border border-[#E7E5E4] bg-[#FAFAF9] px-4 py-3 text-sm focus-visible:border-[#4338CA] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#4338CA]/30" >
- + + } + } @case ('summary') { diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts b/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts index 027f7a4..cb0ea82 100644 --- a/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.spec.ts @@ -25,7 +25,11 @@ describe('RegisterAppointmentComponent', () => { expect(fixture.componentInstance.currentStepId()).toBe('basics'); }); - it('skips follow-up when appointment not performed', () => { + it('always has three wizard steps', () => { + expect(fixture.componentInstance.wizardStepCount).toBe(3); + }); + + it('goes to summary from step 2 when appointment not performed', () => { const component = fixture.componentInstance; component.basicsForm.patchValue({ appointmentDate: '2026-05-20', @@ -38,5 +42,6 @@ describe('RegisterAppointmentComponent', () => { component.onPerformedChange(false); component.nextStep(); expect(component.currentStepId()).toBe('summary'); + expect(component.currentStepIndex()).toBe(2); }); }); diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.ts b/frontend/src/app/features/appointments/register-appointment/register-appointment.ts index 5f5a809..49d5e9d 100644 --- a/frontend/src/app/features/appointments/register-appointment/register-appointment.ts +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.ts @@ -11,7 +11,10 @@ import { import { HealthAppointmentService } from '../services/health-appointment.service'; import { PatientMedicationService } from '../services/patient-medication.service'; -type WizardStepId = 'basics' | 'performed' | 'details-prompt' | 'follow-up' | 'summary'; +type WizardStepId = 'basics' | 'performed' | 'summary'; + +const WIZARD_STEPS: WizardStepId[] = ['basics', 'performed', 'summary']; +const WIZARD_STEP_COUNT = WIZARD_STEPS.length; @Component({ selector: 'app-register-appointment', @@ -60,29 +63,15 @@ export class RegisterAppointmentComponent { guidanceReceived: [''], }); - readonly visibleSteps = computed(() => { - const performed = this.draft().performed; - const wantsDetails = this.draft().wantsFollowUpDetails; - const steps: WizardStepId[] = ['basics', 'performed']; - if (performed === true) { - steps.push('details-prompt'); - if (wantsDetails === true) { - steps.push('follow-up'); - } - } - steps.push('summary'); - return steps; - }); + readonly wizardStepCount = WIZARD_STEP_COUNT; readonly currentStepId = computed( - () => this.visibleSteps()[this.currentStepIndex()] ?? 'basics' + () => WIZARD_STEPS[this.currentStepIndex()] ?? 'basics' ); - readonly progressPercentage = computed(() => { - const total = this.visibleSteps().length; - if (total <= 1) return 100; - return ((this.currentStepIndex() + 1) / total) * 100; - }); + readonly progressPercentage = computed( + () => ((this.currentStepIndex() + 1) / WIZARD_STEP_COUNT) * 100 + ); readonly stepLabel = computed(() => { switch (this.currentStepId()) { @@ -90,12 +79,6 @@ export class RegisterAppointmentComponent { return 'Dados do compromisso'; case 'performed': return 'Status do atendimento'; - case 'details-prompt': - return 'Informações da consulta'; - case 'follow-up': - return this.isSupervisedDoseType() - ? 'Dose e medicamentos' - : 'Detalhes do atendimento'; case 'summary': return 'Revisão'; default: @@ -117,22 +100,10 @@ export class RegisterAppointmentComponent { }); onPerformedChange(value: boolean): void { - this.draft.update((d) => ({ - ...d, - performed: value, - wantsFollowUpDetails: value ? d.wantsFollowUpDetails : null, - })); - this.showValidation.set(false); - } - - onWantsDetailsChange(value: boolean): void { this.draft.update((d) => { - const next = { ...d, wantsFollowUpDetails: value }; + const next = { ...d, performed: value }; if (value && d.type === 'dose_supervisionada') { - next.followUp = { - ...d.followUp, - registerSupervisedDose: true, - }; + next.followUp = { ...d.followUp, registerSupervisedDose: true }; } return next; }); @@ -226,11 +197,6 @@ export class RegisterAppointmentComponent { this.syncFollowUpFromForm(); const fu = this.draft().followUp; - if (fu.hadMedicationChange === null) { - this.showValidation.set(true); - return false; - } - if (fu.hadMedicationChange === true) { const name = fu.newMedicationName.trim(); const dose = fu.newDoseDescription.trim(); @@ -276,29 +242,26 @@ export class RegisterAppointmentComponent { professional: raw.professional ?? '', notes: raw.notes ?? '', }; - if (type === 'dose_supervisionada' && d.wantsFollowUpDetails === true) { + if (type === 'dose_supervisionada' && d.performed === true) { next.followUp = { ...d.followUp, registerSupervisedDose: true }; } return next; }); } - if (stepId === 'performed' && this.draft().performed === null) { - this.showValidation.set(true); - return; - } - - if (stepId === 'details-prompt' && this.draft().wantsFollowUpDetails === null) { - this.showValidation.set(true); - return; - } - - if (stepId === 'follow-up' && !this.validateFollowUpStep()) { - return; + if (stepId === 'performed') { + const { performed } = this.draft(); + if (performed === null) { + this.showValidation.set(true); + return; + } + if (performed === true && !this.validateFollowUpStep()) { + return; + } } this.showValidation.set(false); - const maxIndex = this.visibleSteps().length - 1; + const maxIndex = WIZARD_STEP_COUNT - 1; if (this.currentStepIndex() < maxIndex) { this.currentStepIndex.update((i) => i + 1); } diff --git a/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts b/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts index b8c00b5..158363b 100644 --- a/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts +++ b/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts @@ -18,7 +18,6 @@ describe('HealthAppointmentService', () => { professional: 'Dr. Silva', notes: 'Trazer exames', performed: false, - wantsFollowUpDetails: null, }; beforeEach(() => { @@ -43,10 +42,11 @@ describe('HealthAppointmentService', () => { const record = service.saveFromDraft({ ...baseDraft, performed: true, - wantsFollowUpDetails: false, }); expect(record.status).toBe('completed'); expect(record.performed).toBe(true); + expect(record.wantsFollowUpDetails).toBe(false); + expect(record.followUp).toBeUndefined(); }); it('stores supervised dose when medication unchanged', () => { @@ -54,7 +54,6 @@ describe('HealthAppointmentService', () => { ...baseDraft, type: 'dose_supervisionada', performed: true, - wantsFollowUpDetails: true, followUp: { ...EMPTY_FOLLOW_UP_DRAFT, hadMedicationChange: false, @@ -72,7 +71,6 @@ describe('HealthAppointmentService', () => { const record = service.saveFromDraft({ ...baseDraft, performed: true, - wantsFollowUpDetails: true, followUp: { ...EMPTY_FOLLOW_UP_DRAFT, hadMedicationChange: true, diff --git a/frontend/src/app/features/appointments/services/health-appointment.service.ts b/frontend/src/app/features/appointments/services/health-appointment.service.ts index 5779d7e..d822cb4 100644 --- a/frontend/src/app/features/appointments/services/health-appointment.service.ts +++ b/frontend/src/app/features/appointments/services/health-appointment.service.ts @@ -16,6 +16,7 @@ export class HealthAppointmentService { saveFromDraft(draft: HealthAppointmentDraft): HealthAppointment { const performed = draft.performed === true; + const followUp = performed ? this.buildFollowUp(draft.followUp) : undefined; const record: HealthAppointment = { id: crypto.randomUUID(), appointmentDate: draft.appointmentDate, @@ -26,8 +27,8 @@ export class HealthAppointmentService { notes: draft.notes.trim() || undefined, performed, status: performed ? 'completed' : 'scheduled', - wantsFollowUpDetails: performed && draft.wantsFollowUpDetails === true, - followUp: performed && draft.wantsFollowUpDetails ? this.buildFollowUp(draft.followUp) : undefined, + wantsFollowUpDetails: !!followUp, + followUp, createdAt: new Date().toISOString(), }; From 8dd3800e7cb02a64143b1123177105f01b840aeb Mon Sep 17 00:00:00 2001 From: lawtherea Date: Thu, 21 May 2026 14:50:19 -0300 Subject: [PATCH 09/69] fix: change sentence --- .../appointments/register-appointment/register-appointment.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.html b/frontend/src/app/features/appointments/register-appointment/register-appointment.html index 92472e9..6cada7c 100644 --- a/frontend/src/app/features/appointments/register-appointment/register-appointment.html +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.html @@ -208,7 +208,7 @@

Registrar consulta

@if (draft().performed === true) {

- Você pode incluir conduta, medicamentos e orientações — tudo é opcional. + Você pode incluir conduta, medicamentos e orientações (opcional).

Date: Thu, 21 May 2026 23:50:51 -0300 Subject: [PATCH 10/69] PEQ-78: Implement M3 treatments and doses backend (#17) * feat: add treatment and dose management features - Introduced new models for DoseLog, AdherenceSnapshot, HealthProfessional, Symptom, Treatment, and DoseSchedule. - Implemented repositories for managing doses, health professionals, treatments, and symptoms. - Created use cases for registering doses, creating treatments, and retrieving adherence data. - Added API routes for treatment management, including creating treatments, registering doses, and fetching adherence snapshots. - Updated main.py to include new routers for treatments and symptoms. Co-authored-by: Rafael Luciano * feat: create treatments and related tables - Added migration script to create health_professionals, symptoms, treatments, dose_schedules, and dose_logs tables. - Defined ENUM types for symptom categories, treatment regimens, treatment statuses, and dose frequencies. - Established foreign key relationships for treatments and health professionals. - Included necessary indexes for efficient querying of treatments and dose schedules. Co-authored-by: Rafael Luciano * feat(tests): add integration and unit tests for dose registration and adherence calculation - Introduced integration tests for the dose registration flow, covering scenarios such as successful dose registration, handling of duplicate doses, and access restrictions for health professionals from different units. - Added unit tests for the AdherenceService, validating the calculation of adherence percentages under various conditions, including edge cases and parameterized tests. These tests ensure robust functionality. Co-authored-by: Rafael Luciano * feat: add API endpoints for treatment and dose management - Introduced new endpoints for registering doses, creating treatments, retrieving treatment details, and listing symptoms. - Implemented request and response structures, including necessary authentication and validation. - Added documentation for each endpoint, detailing usage, rate limits, and access restrictions. These additions enhance the treatment management capabilities of the application. Co-authored-by: Rafael Luciano * fix(treatment): apply PR #17 review feedback - Validate token sub/role via get_actor_from_token (401 on corrupt UUID) - Replace deprecated HTTP_422_UNPROCESSABLE_ENTITY with HTTP_422_UNPROCESSABLE_CONTENT - Log duplicate dose attempts in DoseRepository for audit trail - Reduce symptoms endpoint rate limit to 50/minute - Document WHO MDT regimen codes (PB/MB) and add DoseSchedule.validate_dose_mg Co-authored-by: Cursor --------- Co-authored-by: Rafael Luciano Co-authored-by: Cursor --- .../alembic/versions/004_create_treatments.py | 296 ++++++++++++++++ backend/bruno/dose/register_dose.bru | 69 ++++ backend/bruno/treatment/create_treatment.bru | 49 +++ backend/bruno/treatment/get_adherence.bru | 40 +++ backend/bruno/treatment/get_treatment.bru | 34 ++ backend/bruno/treatment/list_symptoms.bru | 31 ++ backend/src/pequi/core/dependencies.py | 23 +- backend/src/pequi/core/exceptions.py | 7 +- backend/src/pequi/main.py | 7 + backend/src/pequi/models/__init__.py | 17 +- backend/src/pequi/models/dose_log.py | 90 +++++ .../src/pequi/models/health_professional.py | 39 +++ backend/src/pequi/models/symptom.py | 27 ++ backend/src/pequi/models/treatment.py | 113 +++++++ backend/src/pequi/repositories/dose_repo.py | 53 +++ .../repositories/health_professional_repo.py | 33 ++ .../src/pequi/repositories/treatment_repo.py | 64 ++++ backend/src/pequi/routers/treatment.py | 146 ++++++++ backend/src/pequi/schemas/dose_log.py | 45 +++ backend/src/pequi/schemas/treatment.py | 62 ++++ .../src/pequi/services/adherence_service.py | 27 ++ .../src/pequi/use_cases/create_treatment.py | 77 +++++ backend/src/pequi/use_cases/get_adherence.py | 83 +++++ backend/src/pequi/use_cases/get_treatment.py | 77 +++++ backend/src/pequi/use_cases/list_symptoms.py | 13 + backend/src/pequi/use_cases/register_dose.py | 134 ++++++++ backend/tests/integration/test_dose_flow.py | 316 ++++++++++++++++++ backend/tests/unit/test_adherence_service.py | 58 ++++ 28 files changed, 2025 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/004_create_treatments.py create mode 100644 backend/bruno/dose/register_dose.bru create mode 100644 backend/bruno/treatment/create_treatment.bru create mode 100644 backend/bruno/treatment/get_adherence.bru create mode 100644 backend/bruno/treatment/get_treatment.bru create mode 100644 backend/bruno/treatment/list_symptoms.bru create mode 100644 backend/src/pequi/models/dose_log.py create mode 100644 backend/src/pequi/models/health_professional.py create mode 100644 backend/src/pequi/models/symptom.py create mode 100644 backend/src/pequi/models/treatment.py create mode 100644 backend/src/pequi/repositories/dose_repo.py create mode 100644 backend/src/pequi/repositories/health_professional_repo.py create mode 100644 backend/src/pequi/repositories/treatment_repo.py create mode 100644 backend/src/pequi/routers/treatment.py create mode 100644 backend/src/pequi/schemas/dose_log.py create mode 100644 backend/src/pequi/schemas/treatment.py create mode 100644 backend/src/pequi/services/adherence_service.py create mode 100644 backend/src/pequi/use_cases/create_treatment.py create mode 100644 backend/src/pequi/use_cases/get_adherence.py create mode 100644 backend/src/pequi/use_cases/get_treatment.py create mode 100644 backend/src/pequi/use_cases/list_symptoms.py create mode 100644 backend/src/pequi/use_cases/register_dose.py create mode 100644 backend/tests/integration/test_dose_flow.py create mode 100644 backend/tests/unit/test_adherence_service.py diff --git a/backend/alembic/versions/004_create_treatments.py b/backend/alembic/versions/004_create_treatments.py new file mode 100644 index 0000000..4a058e8 --- /dev/null +++ b/backend/alembic/versions/004_create_treatments.py @@ -0,0 +1,296 @@ +"""create health_professionals, symptoms, treatments, dose_schedules, dose_logs, +adherence_snapshots tables — M3 Treatments & Doses + +Revision ID: 004_create_treatments +Revises: 003_add_user_foreign_keys +Create Date: 2026-05-20 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "004_create_treatments" +down_revision: str | None = "003_add_user_foreign_keys" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # ENUM types + # ------------------------------------------------------------------ + symptom_category_enum = postgresql.ENUM( + "dermatological", "neurological", "systemic", + name="symptom_category_enum", + ) + treatment_regimen_enum = postgresql.ENUM( + "PB", "MB", + name="treatment_regimen_enum", + ) + treatment_status_enum = postgresql.ENUM( + "active", "completed", "abandoned", "suspended", + name="treatment_status_enum", + ) + dose_frequency_enum = postgresql.ENUM( + "daily", "monthly_supervised", + name="dose_frequency_enum", + ) + + symptom_category_enum.create(op.get_bind(), checkfirst=True) + treatment_regimen_enum.create(op.get_bind(), checkfirst=True) + treatment_status_enum.create(op.get_bind(), checkfirst=True) + dose_frequency_enum.create(op.get_bind(), checkfirst=True) + + # ------------------------------------------------------------------ + # health_professionals — stub mínimo para FK de treatments. + # Campos adicionais (CRM, especialidade) serão adicionados no M8. + # ------------------------------------------------------------------ + op.create_table( + "health_professionals", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("health_unit_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("professional_registration", sa.Text(), nullable=True), + sa.Column("specialty", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.UniqueConstraint("user_id", name="uq_health_professionals_user_id"), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], + name="fk_health_professionals_user_id_users", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["health_unit_id"], ["health_units.id"], + name="fk_health_professionals_health_unit_id_health_units", + ondelete="RESTRICT", + ), + ) + op.create_index( + "ix_health_professionals_health_unit_id", + "health_professionals", + ["health_unit_id"], + ) + + # Adiciona FK de patient_profiles.health_unit_id → health_units.id + # (o campo existia desde 002 mas sem constraint explícita) + op.create_foreign_key( + "fk_patient_profiles_health_unit_id_health_units", + "patient_profiles", + "health_units", + ["health_unit_id"], + ["id"], + ondelete="RESTRICT", + ) + + # ------------------------------------------------------------------ + # symptoms + # ------------------------------------------------------------------ + op.create_table( + "symptoms", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.Text(), nullable=False), + sa.Column( + "category", + sa.Enum( + "dermatological", "neurological", "systemic", + name="symptom_category_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("description", sa.Text(), nullable=True), + sa.UniqueConstraint("name", name="uq_symptoms_name"), + ) + + # ------------------------------------------------------------------ + # treatments + # ------------------------------------------------------------------ + op.create_table( + "treatments", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("prescribed_by", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "regimen", + sa.Enum("PB", "MB", name="treatment_regimen_enum", create_type=False), + nullable=False, + ), + sa.Column("start_date", sa.DATE(), nullable=False), + sa.Column("expected_end", sa.DATE(), nullable=False), + sa.Column( + "status", + sa.Enum( + "active", "completed", "abandoned", "suspended", + name="treatment_status_enum", + create_type=False, + ), + nullable=False, + server_default="active", + ), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["patient_id"], ["patient_profiles.id"], + name="fk_treatments_patient_id_patient_profiles", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["prescribed_by"], ["health_professionals.id"], + name="fk_treatments_prescribed_by_health_professionals", + ondelete="RESTRICT", + ), + ) + op.create_index("ix_treatments_patient_id", "treatments", ["patient_id"]) + op.create_index("ix_treatments_prescribed_by", "treatments", ["prescribed_by"]) + op.create_index("ix_treatments_status", "treatments", ["status"]) + op.create_index("ix_treatments_deleted_at", "treatments", ["deleted_at"]) + + # ------------------------------------------------------------------ + # dose_schedules + # ------------------------------------------------------------------ + op.create_table( + "dose_schedules", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("treatment_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("drug_name", sa.Text(), nullable=False), + sa.Column( + "frequency", + sa.Enum("daily", "monthly_supervised", name="dose_frequency_enum", create_type=False), + nullable=False, + ), + sa.Column("dose_mg", sa.Numeric(6, 2), nullable=True), + sa.Column("month_number", sa.SmallInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["treatment_id"], ["treatments.id"], + name="fk_dose_schedules_treatment_id_treatments", + ondelete="RESTRICT", + ), + ) + op.create_index("ix_dose_schedules_treatment_id", "dose_schedules", ["treatment_id"]) + + # ------------------------------------------------------------------ + # dose_logs + # ------------------------------------------------------------------ + op.create_table( + "dose_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("treatment_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("drug_name", sa.Text(), nullable=False), + sa.Column("expected_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("taken_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("skipped", sa.Boolean(), server_default="false", nullable=False), + sa.Column("skip_reason", sa.Text(), nullable=True), + sa.Column("supervised", sa.Boolean(), server_default="false", nullable=False), + sa.Column("registered_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.UniqueConstraint( + "treatment_id", "drug_name", "expected_at", + name="uq_dose_logs_dedup", + ), + sa.ForeignKeyConstraint( + ["treatment_id"], ["treatments.id"], + name="fk_dose_logs_treatment_id_treatments", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["registered_by"], ["users.id"], + name="fk_dose_logs_registered_by_users", + ondelete="SET NULL", + ), + ) + op.create_index("ix_dose_logs_treatment_id", "dose_logs", ["treatment_id"]) + op.create_index("ix_dose_logs_expected_at", "dose_logs", ["expected_at"]) + + # ------------------------------------------------------------------ + # adherence_snapshots + # ------------------------------------------------------------------ + op.create_table( + "adherence_snapshots", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("treatment_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("period_start", sa.DATE(), nullable=False), + sa.Column("period_end", sa.DATE(), nullable=False), + sa.Column("total_doses", sa.Integer(), nullable=False), + sa.Column("taken_doses", sa.Integer(), nullable=False), + sa.Column("adherence_pct", sa.Numeric(5, 2), nullable=False), + sa.Column( + "calculated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["patient_id"], ["patient_profiles.id"], + name="fk_adherence_snapshots_patient_id_patient_profiles", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["treatment_id"], ["treatments.id"], + name="fk_adherence_snapshots_treatment_id_treatments", + ondelete="RESTRICT", + ), + ) + op.create_index( + "ix_adherence_snapshots_treatment_id", "adherence_snapshots", ["treatment_id"] + ) + op.create_index( + "ix_adherence_snapshots_patient_id", "adherence_snapshots", ["patient_id"] + ) + op.create_index( + "ix_adherence_snapshots_calculated_at", "adherence_snapshots", ["calculated_at"] + ) + + +def downgrade() -> None: + op.drop_table("adherence_snapshots") + op.drop_table("dose_logs") + op.drop_table("dose_schedules") + op.drop_table("treatments") + op.drop_table("symptoms") + + op.drop_constraint( + "fk_patient_profiles_health_unit_id_health_units", + "patient_profiles", + type_="foreignkey", + ) + + op.drop_index("ix_health_professionals_health_unit_id", "health_professionals") + op.drop_table("health_professionals") + + op.execute("DROP TYPE IF EXISTS dose_frequency_enum") + op.execute("DROP TYPE IF EXISTS treatment_status_enum") + op.execute("DROP TYPE IF EXISTS treatment_regimen_enum") + op.execute("DROP TYPE IF EXISTS symptom_category_enum") diff --git a/backend/bruno/dose/register_dose.bru b/backend/bruno/dose/register_dose.bru new file mode 100644 index 0000000..58f8b6b --- /dev/null +++ b/backend/bruno/dose/register_dose.bru @@ -0,0 +1,69 @@ +meta { + name: Register Dose + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/v1/treatments/{{treatmentId}}/doses + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "drug_name": "Dapsona", + "expected_at": "2026-02-15T08:00:00Z", + "taken_at": "2026-02-15T08:30:00Z", + "skipped": false, + "skip_reason": null, + "supervised": false + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.treatment_id: eq "{{treatmentId}}" + res.body.drug_name: eq "Dapsona" + res.body.skipped: eq false + res.body.supervised: eq false + res.body.created_at: isDefined +} + +docs { + Registra uma dose (tomada, pulada ou supervisionada) para o tratamento. + + Regras de negócio: + - Paciente: só pode registrar doses NÃO supervisionadas (`supervised: false`) + do próprio tratamento ativo. + - Profissional: pode registrar doses supervisionadas (`supervised: true`) + com `registered_by` preenchido automaticamente. + - Dose duplicada (mesmo `drug_name` + `expected_at` + tratamento) retorna 409. + + Rate limit: 20/minuto. + + Exemplo de dose supervisionada (para profissional): + { + "drug_name": "Rifampicina", + "expected_at": "2026-02-01T09:00:00Z", + "taken_at": "2026-02-01T09:15:00Z", + "supervised": true + } + + Exemplo de dose pulada: + { + "drug_name": "Dapsona", + "expected_at": "2026-02-16T08:00:00Z", + "skipped": true, + "skip_reason": "Paciente relatou náusea intensa." + } +} diff --git a/backend/bruno/treatment/create_treatment.bru b/backend/bruno/treatment/create_treatment.bru new file mode 100644 index 0000000..24a5b1b --- /dev/null +++ b/backend/bruno/treatment/create_treatment.bru @@ -0,0 +1,49 @@ +meta { + name: Create Treatment + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/v1/treatments + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "patient_id": "{{patientId}}", + "regimen": "PB", + "start_date": "2026-01-15", + "notes": "Tratamento PB iniciado após diagnóstico confirmatório." + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.patient_id: eq "{{patientId}}" + res.body.regimen: eq "PB" + res.body.start_date: isDefined + res.body.expected_end: isDefined + res.body.status: eq "active" +} + +docs { + Cria um novo tratamento MDT para o paciente informado. + + Apenas profissionais de saúde autenticados podem chamar este endpoint. + O campo `expected_end` é calculado automaticamente: + - PB → start_date + 6 meses + - MB → start_date + 12 meses + + Rate limit: 10/minuto por profissional. +} diff --git a/backend/bruno/treatment/get_adherence.bru b/backend/bruno/treatment/get_adherence.bru new file mode 100644 index 0000000..22a8e78 --- /dev/null +++ b/backend/bruno/treatment/get_adherence.bru @@ -0,0 +1,40 @@ +meta { + name: Get Adherence Snapshot + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v1/treatments/{{treatmentId}}/adherence + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: isDefined + res.body.treatment_id: eq "{{treatmentId}}" + res.body.patient_id: isDefined + res.body.period_start: isDefined + res.body.period_end: isDefined + res.body.total_doses: isDefined + res.body.taken_doses: isDefined + res.body.adherence_pct: isDefined + res.body.calculated_at: isDefined +} + +docs { + Retorna o snapshot de adesão mais recente para o tratamento. + + IMPORTANTE: A adesão NUNCA é calculada em tempo real. + Este endpoint lê exclusivamente da tabela `adherence_snapshots`, + populada pelo worker assíncrono (M9). + + Retorna 404 caso nenhum snapshot tenha sido calculado ainda. + + Acessível por paciente (próprio tratamento) e profissional (mesma unidade). + Rate limit: 100/minuto. +} diff --git a/backend/bruno/treatment/get_treatment.bru b/backend/bruno/treatment/get_treatment.bru new file mode 100644 index 0000000..c9d46b7 --- /dev/null +++ b/backend/bruno/treatment/get_treatment.bru @@ -0,0 +1,34 @@ +meta { + name: Get Treatment + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/treatments/{{treatmentId}} + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: eq "{{treatmentId}}" + res.body.patient_id: isDefined + res.body.regimen: isDefined + res.body.status: isDefined + res.body.start_date: isDefined + res.body.expected_end: isDefined +} + +docs { + Retorna os dados de um tratamento pelo ID. + + Acessível por: + - Paciente: somente o próprio tratamento. + - Profissional: somente tratamentos de pacientes da mesma unidade. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/treatment/list_symptoms.bru b/backend/bruno/treatment/list_symptoms.bru new file mode 100644 index 0000000..8866a32 --- /dev/null +++ b/backend/bruno/treatment/list_symptoms.bru @@ -0,0 +1,31 @@ +meta { + name: List Symptoms + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/v1/symptoms + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body: isArray +} + +docs { + Lista todos os sintomas do catálogo (seed-only). + + Acessível por qualquer usuário autenticado (paciente, profissional ou admin). + Os sintomas são agrupados por categoria: + - dermatological + - neurological + - systemic + + Rate limit: 200/minuto. +} diff --git a/backend/src/pequi/core/dependencies.py b/backend/src/pequi/core/dependencies.py index 5a1d217..d562650 100644 --- a/backend/src/pequi/core/dependencies.py +++ b/backend/src/pequi/core/dependencies.py @@ -43,8 +43,26 @@ async def get_token_payload( return payload +def _parse_user_id_from_payload(payload: dict) -> UUID: + try: + return UUID(payload["sub"]) + except (KeyError, ValueError, TypeError) as exc: + raise UnauthorizedError("Invalid token") from exc + + async def get_current_user(payload: dict = Depends(get_token_payload)) -> UUID: - return UUID(payload["sub"]) + return _parse_user_id_from_payload(payload) + + +async def get_actor_from_token( + payload: dict = Depends(get_token_payload), +) -> tuple[UUID, str]: + """Retorna (user_id, role) do token de acesso; 401 se sub ou role inválidos.""" + user_id = _parse_user_id_from_payload(payload) + role = payload.get("role") + if not role or not isinstance(role, str): + raise UnauthorizedError("Invalid token") + return user_id, role class RoleChecker: @@ -54,7 +72,7 @@ def __init__(self, allowed_roles: list[str]): def __call__(self, payload: dict = Depends(get_token_payload)) -> UUID: if payload.get("role") not in self.allowed_roles: raise ForbiddenError(f"Access denied. Allowed roles: {', '.join(self.allowed_roles)}") - return UUID(payload["sub"]) + return _parse_user_id_from_payload(payload) get_current_patient = RoleChecker(["patient"]) @@ -78,6 +96,7 @@ async def get_update_patient_profile_use_case( "get_db", "get_token_payload", "get_current_user", + "get_actor_from_token", "get_current_patient", "get_current_professional", "get_current_admin", diff --git a/backend/src/pequi/core/exceptions.py b/backend/src/pequi/core/exceptions.py index 8f95b69..3665206 100644 --- a/backend/src/pequi/core/exceptions.py +++ b/backend/src/pequi/core/exceptions.py @@ -2,6 +2,9 @@ from fastapi.responses import JSONResponse from pydantic import ValidationError +# Starlette ≥0.40: HTTP_422_UNPROCESSABLE_CONTENT substitui HTTP_422_UNPROCESSABLE_ENTITY +HTTP_422_UNPROCESSABLE = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", 422) + class PequiException(Exception): """Base para exceções de domínio do Pequi.""" @@ -37,7 +40,7 @@ def __init__(self, message: str = "Not authenticated") -> None: class ValidationFailedError(PequiException): def __init__(self, message: str) -> None: - super().__init__(message, status.HTTP_422_UNPROCESSABLE_ENTITY) + super().__init__(message, HTTP_422_UNPROCESSABLE) def register_exception_handlers(app: FastAPI) -> None: @@ -58,7 +61,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe @app.exception_handler(ValidationError) async def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=HTTP_422_UNPROCESSABLE, content={"detail": exc.errors()}, ) diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 27c9b48..e1128a5 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -68,9 +68,16 @@ async def health_check() -> JSONResponse: from pequi.routers import auth as auth_router from pequi.routers import patient as patient_router + from pequi.routers import treatment as treatment_router app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) + app.include_router( + treatment_router.router, prefix="/v1/treatments", tags=["treatments"] + ) + app.include_router( + treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"] + ) # M4: checkin.router → prefix="/v1/checkins" # ... diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index f4ecaaf..363ee5d 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -1,6 +1,21 @@ from pequi.models.consent import Consent +from pequi.models.dose_log import AdherenceSnapshot, DoseLog +from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit from pequi.models.patient import PatientProfile +from pequi.models.symptom import Symptom +from pequi.models.treatment import DoseSchedule, Treatment from pequi.models.user import User -__all__ = ["Consent", "HealthUnit", "PatientProfile", "User"] +__all__ = [ + "AdherenceSnapshot", + "Consent", + "DoseLog", + "DoseSchedule", + "HealthProfessional", + "HealthUnit", + "PatientProfile", + "Symptom", + "Treatment", + "User", +] diff --git a/backend/src/pequi/models/dose_log.py b/backend/src/pequi/models/dose_log.py new file mode 100644 index 0000000..db24a32 --- /dev/null +++ b/backend/src/pequi/models/dose_log.py @@ -0,0 +1,90 @@ +import uuid + +from sqlalchemy import ( + Boolean, + Column, + Date, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class DoseLog(Base): + """Registro individual de dose — tomada, pulada ou perdida. + + Unique constraint ``uq_dose_logs_dedup`` impede duplicidade por + (treatment_id, drug_name, expected_at) — retorna 409 se violada. + ON DELETE RESTRICT para não perder histórico clínico. + """ + + __tablename__ = "dose_logs" + __table_args__ = ( + UniqueConstraint( + "treatment_id", + "drug_name", + "expected_at", + name="uq_dose_logs_dedup", + ), + Index("ix_dose_logs_treatment_id", "treatment_id"), + Index("ix_dose_logs_expected_at", "expected_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + treatment_id = Column( + UUID(as_uuid=True), + ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=False, + ) + drug_name = Column(Text, nullable=False) + expected_at = Column(DateTime(timezone=True), nullable=False) + taken_at = Column(DateTime(timezone=True), nullable=True) + skipped = Column(Boolean, server_default="false", nullable=False, default=False) + skip_reason = Column(Text, nullable=True) + supervised = Column(Boolean, server_default="false", nullable=False, default=False) + registered_by = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class AdherenceSnapshot(Base): + """Snapshot periódico de adesão — calculado exclusivamente pelo worker (M9). + + Nunca recalculado em tempo real. Os endpoints leem apenas desta tabela. + """ + + __tablename__ = "adherence_snapshots" + __table_args__ = ( + Index("ix_adherence_snapshots_treatment_id", "treatment_id"), + Index("ix_adherence_snapshots_patient_id", "patient_id"), + Index("ix_adherence_snapshots_calculated_at", "calculated_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + treatment_id = Column( + UUID(as_uuid=True), + ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=False, + ) + period_start = Column(Date, nullable=False) + period_end = Column(Date, nullable=False) + total_doses = Column(Integer, nullable=False) + taken_doses = Column(Integer, nullable=False) + adherence_pct = Column(Numeric(5, 2), nullable=False) + calculated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/src/pequi/models/health_professional.py b/backend/src/pequi/models/health_professional.py new file mode 100644 index 0000000..72b8dc6 --- /dev/null +++ b/backend/src/pequi/models/health_professional.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class HealthProfessional(Base): + """Perfil de profissional de saúde — stub mínimo para FK de treatments. + + Campos adicionais (CRM, especialidade, etc.) serão expandidos no M8. + """ + + __tablename__ = "health_professionals" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + unique=True, + ) + health_unit_id = Column( + UUID(as_uuid=True), + ForeignKey("health_units.id", ondelete="RESTRICT"), + nullable=False, + ) + professional_registration = Column(String, nullable=True) + specialty = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + nullable=False, + ) + deleted_at = Column(DateTime(timezone=True), nullable=True) diff --git a/backend/src/pequi/models/symptom.py b/backend/src/pequi/models/symptom.py new file mode 100644 index 0000000..9aed152 --- /dev/null +++ b/backend/src/pequi/models/symptom.py @@ -0,0 +1,27 @@ +import uuid +from enum import StrEnum + +from sqlalchemy import Column, Enum, Text +from sqlalchemy.dialects.postgresql import UUID + +from pequi.database import Base + + +class SymptomCategory(StrEnum): + dermatological = "dermatological" + neurological = "neurological" + systemic = "systemic" + + +class Symptom(Base): + """Catálogo de sintomas — populado via seed, sem CRUD público de escrita.""" + + __tablename__ = "symptoms" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(Text, nullable=False, unique=True) + category = Column( + Enum(SymptomCategory, name="symptom_category_enum"), + nullable=False, + ) + description = Column(Text, nullable=True) diff --git a/backend/src/pequi/models/treatment.py b/backend/src/pequi/models/treatment.py new file mode 100644 index 0000000..5415d93 --- /dev/null +++ b/backend/src/pequi/models/treatment.py @@ -0,0 +1,113 @@ +import uuid +from decimal import Decimal +from enum import StrEnum + +from sqlalchemy import Column, Date, DateTime, Enum, ForeignKey, Index, Numeric, SmallInteger, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class TreatmentRegimen(StrEnum): + """Códigos WHO do esquema MDT (abreviações internacionais em inglês). + + PB — Paucibacillary (regime de 6 meses). + MB — Multibacillary (regime de 12 meses). + """ + + PB = "PB" + MB = "MB" + + +class TreatmentStatus(StrEnum): + active = "active" + completed = "completed" + abandoned = "abandoned" + suspended = "suspended" + + +class DoseFrequency(StrEnum): + daily = "daily" + monthly_supervised = "monthly_supervised" + + +class Treatment(Base): + """Tratamento MDT — PB (6 meses) ou MB (12 meses). + + Soft delete via ``deleted_at``. Cascade DELETE proibido por regra de negócio + clínica — usa ON DELETE RESTRICT em todas as FKs que referenciam esta tabela. + """ + + __tablename__ = "treatments" + __table_args__ = ( + Index("ix_treatments_patient_id", "patient_id"), + Index("ix_treatments_prescribed_by", "prescribed_by"), + Index("ix_treatments_status", "status"), + Index("ix_treatments_deleted_at", "deleted_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + prescribed_by = Column( + UUID(as_uuid=True), + ForeignKey("health_professionals.id", ondelete="RESTRICT"), + nullable=False, + ) + regimen = Column( + Enum(TreatmentRegimen, name="treatment_regimen_enum"), + nullable=False, + ) + start_date = Column(Date, nullable=False) + expected_end = Column(Date, nullable=False) + status = Column( + Enum(TreatmentStatus, name="treatment_status_enum"), + nullable=False, + server_default="active", + ) + notes = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + nullable=False, + ) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + +class DoseSchedule(Base): + """Grade de dosagem por fármaco por mês do tratamento. + + Utilizada pelo worker de M9 para gerar DoseLog entries antecipadas. + Sem endpoints CRUD públicos em M3. + """ + + __tablename__ = "dose_schedules" + __table_args__ = (Index("ix_dose_schedules_treatment_id", "treatment_id"),) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + treatment_id = Column( + UUID(as_uuid=True), + ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=False, + ) + drug_name = Column(Text, nullable=False) + frequency = Column( + Enum(DoseFrequency, name="dose_frequency_enum"), + nullable=False, + ) + # Nullable no schema para migrações/import; obrigatório ao popular via worker (M9). + dose_mg = Column(Numeric(6, 2), nullable=True) + month_number = Column(SmallInteger, nullable=True) + + @staticmethod + def validate_dose_mg(dose_mg: Decimal | None, drug_name: str) -> Decimal: + """Garante dose em mg ao criar grades — evita schedules sem dosagem clínica.""" + if dose_mg is None or dose_mg <= 0: + raise ValueError(f"dose_mg is required and must be positive for drug '{drug_name}'") + return dose_mg diff --git a/backend/src/pequi/repositories/dose_repo.py b/backend/src/pequi/repositories/dose_repo.py new file mode 100644 index 0000000..4e35aa9 --- /dev/null +++ b/backend/src/pequi/repositories/dose_repo.py @@ -0,0 +1,53 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.logging import get_logger +from pequi.models.dose_log import DoseLog + +logger = get_logger(__name__) + + +class DoseRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, dose_log: DoseLog) -> DoseLog: + self._session.add(dose_log) + await self._session.flush() + await self._session.refresh(dose_log) + return dose_log + + async def exists_duplicate( + self, + treatment_id: UUID, + drug_name: str, + expected_at: datetime, + ) -> bool: + """Verifica duplicata por treatment_id, drug_name e expected_at.""" + stmt = select(DoseLog.id).where( + DoseLog.treatment_id == treatment_id, + DoseLog.drug_name == drug_name, + DoseLog.expected_at == expected_at, + ) + result = await self._session.execute(stmt) + duplicate = result.scalar_one_or_none() is not None + if duplicate: + logger.warning( + "duplicate_dose_attempt", + treatment_id=str(treatment_id), + drug_name=drug_name, + expected_at=expected_at.isoformat(), + ) + return duplicate + + async def list_by_treatment(self, treatment_id: UUID) -> list[DoseLog]: + stmt = ( + select(DoseLog) + .where(DoseLog.treatment_id == treatment_id) + .order_by(DoseLog.expected_at) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/pequi/repositories/health_professional_repo.py b/backend/src/pequi/repositories/health_professional_repo.py new file mode 100644 index 0000000..f03e9e1 --- /dev/null +++ b/backend/src/pequi/repositories/health_professional_repo.py @@ -0,0 +1,33 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.health_professional import HealthProfessional + + +class HealthProfessionalRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_user_id(self, user_id: UUID) -> HealthProfessional | None: + stmt = select(HealthProfessional).where( + HealthProfessional.user_id == user_id, + HealthProfessional.deleted_at.is_(None), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_id(self, professional_id: UUID) -> HealthProfessional | None: + stmt = select(HealthProfessional).where( + HealthProfessional.id == professional_id, + HealthProfessional.deleted_at.is_(None), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def create(self, professional: HealthProfessional) -> HealthProfessional: + self._session.add(professional) + await self._session.flush() + await self._session.refresh(professional) + return professional diff --git a/backend/src/pequi/repositories/treatment_repo.py b/backend/src/pequi/repositories/treatment_repo.py new file mode 100644 index 0000000..ef81fa4 --- /dev/null +++ b/backend/src/pequi/repositories/treatment_repo.py @@ -0,0 +1,64 @@ +from uuid import UUID + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.dose_log import AdherenceSnapshot +from pequi.models.symptom import Symptom +from pequi.models.treatment import Treatment, TreatmentStatus + + +class TreatmentRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, treatment: Treatment) -> Treatment: + self._session.add(treatment) + await self._session.flush() + await self._session.refresh(treatment) + return treatment + + async def get_by_id(self, treatment_id: UUID) -> Treatment | None: + """Retorna tratamento ativo (não soft-deleted).""" + stmt = select(Treatment).where( + Treatment.id == treatment_id, + Treatment.deleted_at.is_(None), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient_id( + self, + patient_id: UUID, + *, + status: TreatmentStatus | None = None, + ) -> list[Treatment]: + stmt = select(Treatment).where( + Treatment.patient_id == patient_id, + Treatment.deleted_at.is_(None), + ) + if status is not None: + stmt = stmt.where(Treatment.status == status) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_latest_adherence_snapshot(self, treatment_id: UUID) -> AdherenceSnapshot | None: + """Retorna o snapshot de adesão mais recente para o tratamento.""" + stmt = ( + select(AdherenceSnapshot) + .where(AdherenceSnapshot.treatment_id == treatment_id) + .order_by(desc(AdherenceSnapshot.calculated_at)) + .limit(1) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + +class SymptomRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> list[Symptom]: + stmt = select(Symptom).order_by(Symptom.category, Symptom.name) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/pequi/routers/treatment.py b/backend/src/pequi/routers/treatment.py new file mode 100644 index 0000000..7b01fe7 --- /dev/null +++ b/backend/src/pequi/routers/treatment.py @@ -0,0 +1,146 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import ( + get_actor_from_token, + get_current_professional, + get_current_user, + get_db, +) +from pequi.core.rate_limit import limiter +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import SymptomRepository, TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse +from pequi.schemas.treatment import ( + AdherenceSnapshotResponse, + SymptomResponse, + TreatmentCreate, + TreatmentResponse, +) +from pequi.use_cases.create_treatment import CreateTreatmentUseCase +from pequi.use_cases.get_adherence import GetAdherenceUseCase +from pequi.use_cases.get_treatment import GetTreatmentUseCase +from pequi.use_cases.list_symptoms import ListSymptomsUseCase +from pequi.use_cases.register_dose import RegisterDoseUseCase + +router = APIRouter() +symptoms_router = APIRouter() + + +def _make_repos( + session: AsyncSession, +) -> tuple[ + TreatmentRepository, + PatientRepository, + HealthProfessionalRepository, + DoseRepository, + SymptomRepository, +]: + return ( + TreatmentRepository(session), + PatientRepository(session), + HealthProfessionalRepository(session), + DoseRepository(session), + SymptomRepository(session), + ) + + +# --------------------------------------------------------------------------- +# POST /v1/treatments — apenas profissionais +# --------------------------------------------------------------------------- + + +@router.post("", response_model=TreatmentResponse, status_code=201) +@limiter.limit("10/minute") +async def create_treatment( + request: Request, + body: TreatmentCreate, + professional_user_id: UUID = Depends(get_current_professional), + session: AsyncSession = Depends(get_db), +) -> TreatmentResponse: + treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) + use_case = CreateTreatmentUseCase(treatment_repo, patient_repo, professional_repo) + return await use_case.execute(professional_user_id, body) + + +# --------------------------------------------------------------------------- +# GET /v1/treatments/{id} — paciente ou profissional +# --------------------------------------------------------------------------- + + +@router.get("/{treatment_id}", response_model=TreatmentResponse) +@limiter.limit("100/minute") +async def get_treatment( + request: Request, + treatment_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> TreatmentResponse: + actor_user_id, actor_role = actor + + treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) + use_case = GetTreatmentUseCase(treatment_repo, patient_repo, professional_repo) + return await use_case.execute(actor_user_id, actor_role, treatment_id) + + +# --------------------------------------------------------------------------- +# POST /v1/treatments/{id}/doses — paciente ou profissional +# --------------------------------------------------------------------------- + + +@router.post("/{treatment_id}/doses", response_model=DoseLogResponse, status_code=201) +@limiter.limit("20/minute") +async def register_dose( + request: Request, + treatment_id: UUID, + body: DoseLogCreate, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> DoseLogResponse: + actor_user_id, actor_role = actor + + treatment_repo, patient_repo, professional_repo, dose_repo, _ = _make_repos(session) + use_case = RegisterDoseUseCase(treatment_repo, dose_repo, patient_repo, professional_repo) + return await use_case.execute(actor_user_id, actor_role, treatment_id, body) + + +# --------------------------------------------------------------------------- +# GET /v1/treatments/{id}/adherence — paciente ou profissional +# --------------------------------------------------------------------------- + + +@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponse) +@limiter.limit("100/minute") +async def get_adherence( + request: Request, + treatment_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> AdherenceSnapshotResponse: + actor_user_id, actor_role = actor + + treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) + use_case = GetAdherenceUseCase(treatment_repo, patient_repo, professional_repo) + return await use_case.execute(actor_user_id, actor_role, treatment_id) + + +# --------------------------------------------------------------------------- +# GET /v1/symptoms — qualquer usuário autenticado +# Registrado em main.py como prefix="/v1/symptoms" +# --------------------------------------------------------------------------- + + +@symptoms_router.get("", response_model=list[SymptomResponse]) +@limiter.limit("50/minute") +async def list_symptoms( + request: Request, + _user_id: UUID = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +) -> list[SymptomResponse]: + _, _, _, _, symptom_repo = _make_repos(session) + use_case = ListSymptomsUseCase(symptom_repo) + return await use_case.execute() diff --git a/backend/src/pequi/schemas/dose_log.py b/backend/src/pequi/schemas/dose_log.py new file mode 100644 index 0000000..e64b2a0 --- /dev/null +++ b/backend/src/pequi/schemas/dose_log.py @@ -0,0 +1,45 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DoseLogCreate(BaseModel): + """Payload para registrar uma dose (tomada, pulada ou supervisionada). + + Validações de permissão (paciente vs. profissional, supervisionada vs. diária) + são realizadas no use case, não aqui. + """ + + model_config = ConfigDict(extra="forbid") + + drug_name: str = Field(..., min_length=1, max_length=200) + expected_at: datetime + taken_at: datetime | None = None + skipped: bool = False + skip_reason: str | None = Field(default=None, max_length=500) + supervised: bool = False + + @model_validator(mode="after") + def validate_skip_and_taken(self) -> "DoseLogCreate": + if self.skipped and self.taken_at is not None: + raise ValueError("Uma dose não pode ser simultaneamente tomada e pulada.") + if self.skipped is False and self.taken_at is None and not self.supervised: + # Permite dose "pendente" (nem tomada nem pulada) apenas se não for o caso base + pass + return self + + +class DoseLogResponse(BaseModel): + id: UUID + treatment_id: UUID + drug_name: str + expected_at: datetime + taken_at: datetime | None = None + skipped: bool + skip_reason: str | None = None + supervised: bool + registered_by: UUID | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/treatment.py b/backend/src/pequi/schemas/treatment.py new file mode 100644 index 0000000..c3d9233 --- /dev/null +++ b/backend/src/pequi/schemas/treatment.py @@ -0,0 +1,62 @@ +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class TreatmentCreate(BaseModel): + """Payload para criar um novo tratamento MDT. + + ``expected_end`` é calculado automaticamente pelo use case: + PB = start_date + 6 meses, MB = start_date + 12 meses. + """ + + model_config = ConfigDict(extra="forbid") + + patient_id: UUID + regimen: str = Field( + ..., + pattern="^(PB|MB)$", + description="WHO MDT code: PB (paucibacillary, 6mo) or MB (multibacillary, 12mo)", + ) + start_date: date + notes: str | None = Field(default=None, max_length=2000) + + +class TreatmentResponse(BaseModel): + id: UUID + patient_id: UUID + prescribed_by: UUID + regimen: str + start_date: date + expected_end: date + status: str + notes: str | None = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class AdherenceSnapshotResponse(BaseModel): + id: UUID + patient_id: UUID + treatment_id: UUID + period_start: date + period_end: date + total_doses: int + taken_doses: int + adherence_pct: Decimal + calculated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class SymptomResponse(BaseModel): + id: UUID + name: str + category: str + description: str | None = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/services/adherence_service.py b/backend/src/pequi/services/adherence_service.py new file mode 100644 index 0000000..0d6550a --- /dev/null +++ b/backend/src/pequi/services/adherence_service.py @@ -0,0 +1,27 @@ +from decimal import ROUND_HALF_UP, Decimal + + +class AdherenceService: + """Cálculo stateless de percentual de adesão. + + Não acessa banco de dados. Recebe contadores brutos e retorna o percentual + arredondado a 2 casas decimais. Use cases leem snapshots do banco; este + serviço é usado pelo worker (M9) para gerar os valores antes de persisti-los. + """ + + @staticmethod + def calculate_pct(total_doses: int, taken_doses: int) -> Decimal: + """Retorna percentual de adesão como Decimal com 2 casas decimais. + + Args: + total_doses: Total de doses previstas no período. + taken_doses: Total de doses efetivamente tomadas. + + Returns: + Decimal entre 0.00 e 100.00, arredondado por ROUND_HALF_UP. + """ + if total_doses == 0: + return Decimal("0.00") + + pct = Decimal(taken_doses) / Decimal(total_doses) * Decimal("100") + return pct.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) diff --git a/backend/src/pequi/use_cases/create_treatment.py b/backend/src/pequi/use_cases/create_treatment.py new file mode 100644 index 0000000..04b135c --- /dev/null +++ b/backend/src/pequi/use_cases/create_treatment.py @@ -0,0 +1,77 @@ +import calendar +import uuid +from datetime import date + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.treatment import TreatmentCreate, TreatmentResponse + +_REGIMEN_MONTHS = { + TreatmentRegimen.PB: 6, + TreatmentRegimen.MB: 12, +} + + +class CreateTreatmentUseCase: + """Cria um tratamento MDT para um paciente. + + Apenas profissionais de saúde podem criar tratamentos, e somente para + pacientes da mesma unidade de saúde. + """ + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + professional_user_id: uuid.UUID, + data: TreatmentCreate, + ) -> TreatmentResponse: + professional = await self._professional_repo.get_by_user_id(professional_user_id) + if professional is None: + raise NotFoundError("HealthProfessional", str(professional_user_id)) + + patient = await self._patient_repo.get_by_id(data.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(data.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a pacientes de outra unidade de saúde." + ) + + regimen = TreatmentRegimen(data.regimen) + expected_end = _calculate_expected_end(data.start_date, regimen) + + treatment = Treatment( + id=uuid.uuid4(), + patient_id=data.patient_id, + prescribed_by=professional.id, + regimen=regimen, + start_date=data.start_date, + expected_end=expected_end, + status=TreatmentStatus.active, + notes=data.notes, + ) + treatment = await self._treatment_repo.create(treatment) + return TreatmentResponse.model_validate(treatment) + + +def _calculate_expected_end(start_date: date, regimen: TreatmentRegimen) -> date: + """Adiciona N meses à data de início, limitando ao último dia do mês destino.""" + months = _REGIMEN_MONTHS[regimen] + total_months = start_date.month - 1 + months + year = start_date.year + total_months // 12 + month = total_months % 12 + 1 + day = min(start_date.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) diff --git a/backend/src/pequi/use_cases/get_adherence.py b/backend/src/pequi/use_cases/get_adherence.py new file mode 100644 index 0000000..9324414 --- /dev/null +++ b/backend/src/pequi/use_cases/get_adherence.py @@ -0,0 +1,83 @@ +import uuid + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.treatment import AdherenceSnapshotResponse + + +class GetAdherenceUseCase: + """Retorna o snapshot de adesão mais recente para um tratamento. + + Nunca recalcula — lê exclusivamente de ``adherence_snapshots``. + Retorna NotFoundError se nenhum snapshot foi calculado ainda pelo worker. + """ + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + ) -> AdherenceSnapshotResponse: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + await _assert_access( + actor_user_id=actor_user_id, + actor_role=actor_role, + treatment=treatment, + patient_repo=self._patient_repo, + professional_repo=self._professional_repo, + ) + + snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment_id) + if snapshot is None: + raise NotFoundError( + "AdherenceSnapshot", + "Nenhum snapshot calculado ainda para este tratamento.", + ) + + return AdherenceSnapshotResponse.model_validate(snapshot) + + +async def _assert_access( + *, + actor_user_id: uuid.UUID, + actor_role: str, + treatment, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, +) -> None: + if actor_role == "patient": + patient = await patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + elif actor_role == "health_professional": + professional = await professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await patient_repo.get_by_id(treatment.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(treatment.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + else: + raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/get_treatment.py b/backend/src/pequi/use_cases/get_treatment.py new file mode 100644 index 0000000..2019491 --- /dev/null +++ b/backend/src/pequi/use_cases/get_treatment.py @@ -0,0 +1,77 @@ +import uuid + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.treatment import TreatmentResponse + + +class GetTreatmentUseCase: + """Retorna um tratamento verificando permissões de acesso. + + - Paciente: só acessa o próprio tratamento. + - Profissional: só acessa tratamentos de pacientes da mesma unidade. + """ + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + ) -> TreatmentResponse: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + await _assert_access( + actor_user_id=actor_user_id, + actor_role=actor_role, + treatment=treatment, + patient_repo=self._patient_repo, + professional_repo=self._professional_repo, + ) + + return TreatmentResponse.model_validate(treatment) + + +async def _assert_access( + *, + actor_user_id: uuid.UUID, + actor_role: str, + treatment, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, +) -> None: + """Verifica se o ator tem permissão para acessar o tratamento.""" + if actor_role == "patient": + patient = await patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + elif actor_role == "health_professional": + professional = await professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await patient_repo.get_by_id(treatment.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(treatment.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + else: + raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/list_symptoms.py b/backend/src/pequi/use_cases/list_symptoms.py new file mode 100644 index 0000000..984d861 --- /dev/null +++ b/backend/src/pequi/use_cases/list_symptoms.py @@ -0,0 +1,13 @@ +from pequi.repositories.treatment_repo import SymptomRepository +from pequi.schemas.treatment import SymptomResponse + + +class ListSymptomsUseCase: + """Lista todos os sintomas do catálogo (seed-only, sem paginação).""" + + def __init__(self, symptom_repo: SymptomRepository) -> None: + self._symptom_repo = symptom_repo + + async def execute(self) -> list[SymptomResponse]: + symptoms = await self._symptom_repo.list_all() + return [SymptomResponse.model_validate(s) for s in symptoms] diff --git a/backend/src/pequi/use_cases/register_dose.py b/backend/src/pequi/use_cases/register_dose.py new file mode 100644 index 0000000..1e1bf4c --- /dev/null +++ b/backend/src/pequi/use_cases/register_dose.py @@ -0,0 +1,134 @@ +import uuid + +from pequi.core.exceptions import ( + ConflictError, + ForbiddenError, + NotFoundError, + ValidationFailedError, +) +from pequi.models.dose_log import DoseLog +from pequi.models.treatment import TreatmentStatus +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse + + +class RegisterDoseUseCase: + """Registra uma dose (tomada, pulada ou supervisionada). + + Regras de negócio: + - Paciente só pode autoregistrar doses não supervisionadas do próprio tratamento ativo. + - Dose supervisionada deve ser registrada por profissional (registered_by != null). + - Profissional de outra unidade não pode registrar doses no tratamento. + - Duplicidade (treatment_id + drug_name + expected_at) retorna ConflictError → HTTP 409. + """ + + def __init__( + self, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + data: DoseLogCreate, + ) -> DoseLogResponse: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + registered_by: uuid.UUID | None = None + + if actor_role == "patient": + registered_by = await self._validate_patient_access( + actor_user_id=actor_user_id, + treatment=treatment, + data=data, + ) + elif actor_role == "health_professional": + registered_by = await self._validate_professional_access( + actor_user_id=actor_user_id, + treatment=treatment, + data=data, + ) + else: + raise ForbiddenError("Acesso negado.") + + duplicate = await self._dose_repo.exists_duplicate( + treatment_id=treatment_id, + drug_name=data.drug_name, + expected_at=data.expected_at, + ) + if duplicate: + raise ConflictError( + f"Dose duplicada: já existe registro para '{data.drug_name}' " + f"em {data.expected_at.isoformat()} neste tratamento." + ) + + dose_log = DoseLog( + id=uuid.uuid4(), + treatment_id=treatment_id, + drug_name=data.drug_name, + expected_at=data.expected_at, + taken_at=data.taken_at, + skipped=data.skipped, + skip_reason=data.skip_reason, + supervised=data.supervised, + registered_by=registered_by, + ) + dose_log = await self._dose_repo.create(dose_log) + return DoseLogResponse.model_validate(dose_log) + + async def _validate_patient_access( + self, + actor_user_id: uuid.UUID, + treatment, + data: DoseLogCreate, + ) -> None: + if data.supervised: + raise ForbiddenError("Paciente não pode registrar doses supervisionadas.") + + patient = await self._patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + if treatment.status != TreatmentStatus.active: + raise ValidationFailedError( + "Autoregistro permitido apenas em tratamentos com status 'active'." + ) + + return None + + async def _validate_professional_access( + self, + actor_user_id: uuid.UUID, + treatment, + data: DoseLogCreate, + ) -> uuid.UUID: + professional = await self._professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await self._patient_repo.get_by_id(treatment.patient_id) + if patient is None or patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + if data.supervised and professional is None: + raise ValidationFailedError( + "Dose supervisionada deve ser registrada por um profissional." + ) + + return professional.user_id diff --git a/backend/tests/integration/test_dose_flow.py b/backend/tests/integration/test_dose_flow.py new file mode 100644 index 0000000..79a9023 --- /dev/null +++ b/backend/tests/integration/test_dose_flow.py @@ -0,0 +1,316 @@ +"""Testes de integração para o fluxo de registro de doses (M3). + +Usa banco de dados real (PostgreSQL via conftest). Não usa HTTP — chama +use cases diretamente para testar o comportamento observável no banco. +""" + +from datetime import UTC, date, datetime +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ConflictError, ForbiddenError, NotFoundError +from pequi.models.dose_log import AdherenceSnapshot +from pequi.models.health_professional import HealthProfessional +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate +from pequi.use_cases.get_adherence import GetAdherenceUseCase +from pequi.use_cases.register_dose import RegisterDoseUseCase + +# --------------------------------------------------------------------------- +# Helpers de fixtures +# --------------------------------------------------------------------------- + + +async def _create_health_unit(session, *, name: str = "UBS Central") -> HealthUnit: + hu = HealthUnit(id=uuid4(), name=name, city="Cidade", state="SP", cnes=str(uuid4())[:11]) + session.add(hu) + await session.flush() + return hu + + +async def _create_user(session, *, email: str, role: str) -> User: + user = User( + id=uuid4(), + email=email, + hashed_password="$2b$12$placeholder", + full_name="Test User", + role=role, + ) + session.add(user) + await session.flush() + return user + + +async def _create_patient(session, *, user: User, health_unit: HealthUnit) -> PatientProfile: + patient = PatientProfile( + id=uuid4(), + user_id=user.id, + health_unit_id=health_unit.id, + date_of_birth=date(1985, 3, 10), + ) + session.add(patient) + await session.flush() + return patient + + +async def _create_professional( + session, *, user: User, health_unit: HealthUnit +) -> HealthProfessional: + professional = HealthProfessional( + id=uuid4(), + user_id=user.id, + health_unit_id=health_unit.id, + ) + session.add(professional) + await session.flush() + return professional + + +async def _create_treatment( + session, + *, + patient: PatientProfile, + professional: HealthProfessional, + status: TreatmentStatus = TreatmentStatus.active, +) -> Treatment: + treatment = Treatment( + id=uuid4(), + patient_id=patient.id, + prescribed_by=professional.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 1, 1), + expected_end=date(2026, 7, 1), + status=status, + ) + session.add(treatment) + await session.flush() + return treatment + + +def _make_use_case(session) -> RegisterDoseUseCase: + return RegisterDoseUseCase( + TreatmentRepository(session), + DoseRepository(session), + PatientRepository(session), + HealthProfessionalRepository(session), + ) + + +# --------------------------------------------------------------------------- +# Testes +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_patient_can_register_daily_dose(create_tables, db_session): + """Paciente registra dose diária do próprio tratamento ativo com sucesso.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient1@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof1@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + + data = DoseLogCreate( + drug_name="Dapsona", + expected_at=datetime(2026, 2, 15, 8, 0, tzinfo=UTC), + taken_at=datetime(2026, 2, 15, 8, 30, tzinfo=UTC), + ) + + use_case = _make_use_case(db_session) + result = await use_case.execute(patient_user.id, "patient", treatment.id, data) + + assert result.id is not None + assert result.treatment_id == treatment.id + assert result.drug_name == "Dapsona" + assert result.supervised is False + assert result.registered_by is None + + +@pytest.mark.asyncio +async def test_duplicate_dose_returns_conflict(create_tables, db_session): + """Dose duplicada (treatment_id + drug_name + expected_at) lança ConflictError.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient2@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof2@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + + expected_at = datetime(2026, 3, 1, 8, 0, tzinfo=UTC) + data = DoseLogCreate(drug_name="Clofazimina", expected_at=expected_at) + + use_case = _make_use_case(db_session) + await use_case.execute(patient_user.id, "patient", treatment.id, data) + + with pytest.raises(ConflictError): + await use_case.execute(patient_user.id, "patient", treatment.id, data) + + +@pytest.mark.asyncio +async def test_professional_from_another_unit_cannot_access(create_tables, db_session): + """Profissional de outra unidade não pode registrar dose no tratamento.""" + unit_a = await _create_health_unit(db_session, name="UBS Norte") + unit_b = await _create_health_unit(db_session, name="UBS Sul") + + patient_user = await _create_user(db_session, email="patient3@test.com", role="patient") + prof_a_user = await _create_user( + db_session, email="prof_a@test.com", role="health_professional" + ) + prof_b_user = await _create_user( + db_session, email="prof_b@test.com", role="health_professional" + ) + + patient = await _create_patient(db_session, user=patient_user, health_unit=unit_a) + prof_a = await _create_professional(db_session, user=prof_a_user, health_unit=unit_a) + await _create_professional(db_session, user=prof_b_user, health_unit=unit_b) + + treatment = await _create_treatment(db_session, patient=patient, professional=prof_a) + + data = DoseLogCreate( + drug_name="Rifampicina", + expected_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), + ) + + use_case = _make_use_case(db_session) + + with pytest.raises(ForbiddenError): + await use_case.execute(prof_b_user.id, "health_professional", treatment.id, data) + + +@pytest.mark.asyncio +async def test_patient_cannot_register_supervised_dose(create_tables, db_session): + """Paciente não pode registrar dose supervisionada — retorna ForbiddenError.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient4@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof4@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + + data = DoseLogCreate( + drug_name="Rifampicina", + expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), + supervised=True, + ) + + use_case = _make_use_case(db_session) + + with pytest.raises(ForbiddenError): + await use_case.execute(patient_user.id, "patient", treatment.id, data) + + +@pytest.mark.asyncio +async def test_patient_cannot_register_dose_on_inactive_treatment(create_tables, db_session): + """Paciente não pode autoregistrar em tratamento não-ativo.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient5@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof5@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment( + db_session, + patient=patient, + professional=professional, + status=TreatmentStatus.completed, + ) + + data = DoseLogCreate( + drug_name="Dapsona", + expected_at=datetime(2026, 8, 1, 8, 0, tzinfo=UTC), + ) + + use_case = _make_use_case(db_session) + + from pequi.core.exceptions import ValidationFailedError + + with pytest.raises(ValidationFailedError): + await use_case.execute(patient_user.id, "patient", treatment.id, data) + + +@pytest.mark.asyncio +async def test_get_adherence_returns_latest_snapshot(create_tables, db_session): + """get_adherence retorna o snapshot mais recente quando disponível.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient6@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof6@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + + snapshot = AdherenceSnapshot( + id=uuid4(), + patient_id=patient.id, + treatment_id=treatment.id, + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + total_doses=30, + taken_doses=25, + adherence_pct="83.33", + calculated_at=datetime(2026, 2, 1, 0, 0, tzinfo=UTC), + ) + db_session.add(snapshot) + await db_session.flush() + + use_case = GetAdherenceUseCase( + TreatmentRepository(db_session), + PatientRepository(db_session), + HealthProfessionalRepository(db_session), + ) + result = await use_case.execute(patient_user.id, "patient", treatment.id) + + assert result.treatment_id == treatment.id + assert result.total_doses == 30 + assert result.taken_doses == 25 + + +@pytest.mark.asyncio +async def test_get_adherence_raises_not_found_when_no_snapshot(create_tables, db_session): + """Sem snapshot calculado, get_adherence lança NotFoundError.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient7@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof7@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + + use_case = GetAdherenceUseCase( + TreatmentRepository(db_session), + PatientRepository(db_session), + HealthProfessionalRepository(db_session), + ) + + with pytest.raises(NotFoundError): + await use_case.execute(patient_user.id, "patient", treatment.id) + + +@pytest.mark.asyncio +async def test_professional_registers_supervised_dose(create_tables, db_session): + """Profissional registra dose supervisionada com registered_by preenchido.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient8@test.com", role="patient") + prof_user = await _create_user(db_session, email="prof8@test.com", role="health_professional") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + + data = DoseLogCreate( + drug_name="Rifampicina", + expected_at=datetime(2026, 2, 1, 9, 0, tzinfo=UTC), + taken_at=datetime(2026, 2, 1, 9, 15, tzinfo=UTC), + supervised=True, + ) + + use_case = _make_use_case(db_session) + result = await use_case.execute(prof_user.id, "health_professional", treatment.id, data) + + assert result.supervised is True + assert result.registered_by == prof_user.id diff --git a/backend/tests/unit/test_adherence_service.py b/backend/tests/unit/test_adherence_service.py new file mode 100644 index 0000000..e78726d --- /dev/null +++ b/backend/tests/unit/test_adherence_service.py @@ -0,0 +1,58 @@ +"""Testes unitários para AdherenceService.calculate_pct. + +Sem banco de dados, sem HTTP — isolamento total. +Cobre os três casos exigidos nos critérios de aceite do M3. +""" + +from decimal import Decimal + +import pytest + +from pequi.services.adherence_service import AdherenceService + + +class TestAdherenceServiceCalculatePct: + def test_zero_percent_when_no_doses_taken(self) -> None: + result = AdherenceService.calculate_pct(total_doses=10, taken_doses=0) + assert result == Decimal("0.00") + + def test_zero_percent_when_total_is_zero(self) -> None: + """Divisão por zero retorna 0.00 sem exceção.""" + result = AdherenceService.calculate_pct(total_doses=0, taken_doses=0) + assert result == Decimal("0.00") + + def test_thirty_three_percent(self) -> None: + """1 de 3 doses tomadas → 33.33%.""" + result = AdherenceService.calculate_pct(total_doses=3, taken_doses=1) + assert result == Decimal("33.33") + + def test_one_hundred_percent(self) -> None: + result = AdherenceService.calculate_pct(total_doses=6, taken_doses=6) + assert result == Decimal("100.00") + + def test_returns_decimal_type(self) -> None: + result = AdherenceService.calculate_pct(total_doses=4, taken_doses=1) + assert isinstance(result, Decimal) + + def test_rounds_to_two_decimal_places(self) -> None: + """2/3 = 66.666... → arredonda para 66.67.""" + result = AdherenceService.calculate_pct(total_doses=3, taken_doses=2) + assert result == Decimal("66.67") + + def test_fifty_percent(self) -> None: + result = AdherenceService.calculate_pct(total_doses=10, taken_doses=5) + assert result == Decimal("50.00") + + @pytest.mark.parametrize( + ("total", "taken", "expected"), + [ + (1, 1, Decimal("100.00")), + (2, 1, Decimal("50.00")), + (7, 1, Decimal("14.29")), + (180, 120, Decimal("66.67")), + ], + ) + def test_parametric_cases( + self, total: int, taken: int, expected: Decimal + ) -> None: + assert AdherenceService.calculate_pct(total, taken) == expected From 886005f6273e334caaa78eed13fc9f0c24ed44d4 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Sun, 24 May 2026 16:17:22 -0300 Subject: [PATCH 11/69] PEQ-126: Corrige URL de teste PostgreSQL no CI (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(config): add method to safely derive test database URL - Introduced `get_test_database_url` method in the Settings class to handle test database URL derivation securely, avoiding potential user corruption in the URL. - Updated test configuration to utilize the new method for obtaining the test database URL. - Added unit tests to ensure correct behavior of the new method under various scenarios, including explicit settings and CI configurations. Co-authored-by: Rafael Luciano * chore(ci): enhance workflow configurations and environment setup - Updated build and test workflows to improve environment variable handling, including the addition of DATABASE_URL_TEST for testing purposes. - Refactored the creation of the .env file to use a heredoc for better readability and maintainability. - Enhanced CI triggers to include release, hotfix, and feature branches for more comprehensive coverage. - Improved health check configurations for PostgreSQL and Redis services in the test environment. These changes aim to streamline the CI/CD process and ensure a more robust testing environment. Co-authored-by: Rafael Luciano * chore: apply ruff format to files failing CI lint Co-authored-by: Cursor * fix(alembic): evita DuplicateObjectError em ENUMs da migration 004 Centraliza tipos PostgreSQL em pequi.db.pg_enums e reutiliza postgresql.ENUM com create_type=False nas colunas após create único. Co-authored-by: Cursor --------- Co-authored-by: Rafael Luciano Co-authored-by: Cursor --- .github/workflows/build.yml | 25 ++--- .github/workflows/ci.yml | 19 ++-- .github/workflows/tests.yml | 89 ++++++++++++----- .../alembic/versions/004_create_treatments.py | 96 ++++++++----------- backend/src/pequi/config.py | 11 +++ backend/src/pequi/db/__init__.py | 1 + backend/src/pequi/db/pg_enums.py | 81 ++++++++++++++++ backend/src/pequi/main.py | 8 +- backend/tests/conftest.py | 4 +- backend/tests/unit/test_adherence_service.py | 4 +- .../tests/unit/test_config_database_url.py | 32 +++++++ 11 files changed, 258 insertions(+), 112 deletions(-) create mode 100644 backend/src/pequi/db/__init__.py create mode 100644 backend/src/pequi/db/pg_enums.py create mode 100644 backend/tests/unit/test_config_database_url.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d00f3e3..0b2e2bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,18 +78,19 @@ jobs: run: uv sync --frozen --extra dev - name: Create env - env: - ENV_CONTENT: | - ENV=development - SECRET_KEY=test-secret-do-not-use-in-production - DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test - REDIS_URL=redis://localhost:6379/0 - SENTRY_DSN= - STORAGE_ENDPOINT=http://localhost:9000 - STORAGE_ACCESS_KEY=minioadmin - STORAGE_SECRET_KEY=minioadmin - STORAGE_BUCKET_IMAGES=pequi-images - run: printf '%s' "$ENV_CONTENT" > .env + run: | + cat > .env <<'EOF' + ENV=development + SECRET_KEY=test-secret-do-not-use-in-production + DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + DATABASE_URL_TEST=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + REDIS_URL=redis://localhost:6379/0 + SENTRY_DSN= + STORAGE_ENDPOINT=http://localhost:9000 + STORAGE_ACCESS_KEY=minioadmin + STORAGE_SECRET_KEY=minioadmin + STORAGE_BUCKET_IMAGES=pequi-images + EOF - name: Run migrations run: uv run alembic upgrade head diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 209fcf9..71824c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,19 +1,28 @@ name: CI +# GitFlow: integração contínua em branches de longa duração, release, hotfix e feature. +# PRs disparam CI no branch *base* (development / main). +# Push em feature/* cobre WIP sem PR aberto; com PR aberto pode haver 2 runs — ver concurrency. on: pull_request: branches: - - development - main + - development + - "release/**" + - "hotfix/**" + push: branches: - - development - main + - development + - "release/**" + - "hotfix/**" + - "feature/**" + workflow_dispatch: -# Cancela runs anteriores do mesmo branch/PR ao receber novo push concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -24,8 +33,6 @@ jobs: needs: lint uses: ./.github/workflows/tests.yml - # secrets: inherit repassa GITHUB_TOKEN para Gitleaks e demais scanners - # permissions: o caller deve conceder o que o reusable workflow pede (security-events: write) security: needs: lint uses: ./.github/workflows/security.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1332ffc..81d7298 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,13 +1,25 @@ name: Tests -# Só é chamado pelo ci.yml — sem triggers próprios para evitar double execution +# Chamado apenas por ci.yml — sem triggers próprios (evita double execution). on: workflow_call: env: TZ: UTC LANG: C.UTF-8 - PYTHONUNBUFFERED: 1 + PYTHONUNBUFFERED: "1" + # Variáveis de job têm precedência sobre .env (Pydantic Settings). + # DATABASE_URL_TEST explícito evita fallback quebrado no conftest quando só o DB de teste existe no CI. + DATABASE_URL: postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + DATABASE_URL_TEST: postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + REDIS_URL: redis://localhost:6379/0 + SECRET_KEY: test-secret-do-not-use-in-production + ENV: development + SENTRY_DSN: "" + STORAGE_ENDPOINT: http://localhost:9000 + STORAGE_ACCESS_KEY: minioadmin + STORAGE_SECRET_KEY: minioadmin + STORAGE_BUCKET_IMAGES: pequi-images defaults: run: @@ -27,10 +39,10 @@ jobs: ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U pequi" - --health-interval 10s + --health-cmd "pg_isready -U pequi -d pequi_test" + --health-interval 5s --health-timeout 5s - --health-retries 10 + --health-retries 12 redis: image: redis:7-alpine @@ -38,9 +50,9 @@ jobs: - 6379:6379 options: >- --health-cmd "redis-cli ping" - --health-interval 10s + --health-interval 5s --health-timeout 5s - --health-retries 10 + --health-retries 12 steps: - name: Checkout @@ -66,8 +78,6 @@ jobs: - name: Install dependencies run: uv sync --frozen --extra dev - # MinIO não suporta service container direto no GH Actions (precisa de CMD server /data) - # Iniciamos via docker run na etapa de setup - name: Start MinIO run: | docker run -d \ @@ -82,24 +92,55 @@ jobs: 'until curl -sf http://localhost:9000/minio/health/live; do sleep 2; done' echo "MinIO pronto" - # Escrita via variável de ambiente para evitar o bug de leading-spaces do heredoc YAML - - name: Create test env + # .env espelha o ambiente local; env do job já garante valores corretos se o arquivo falhar. + - name: Create test env file + run: | + cat > .env <<'EOF' + ENV=development + SECRET_KEY=test-secret-do-not-use-in-production + DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + DATABASE_URL_TEST=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test + REDIS_URL=redis://localhost:6379/0 + SENTRY_DSN= + STORAGE_ENDPOINT=http://localhost:9000 + STORAGE_ACCESS_KEY=minioadmin + STORAGE_SECRET_KEY=minioadmin + STORAGE_BUCKET_IMAGES=pequi-images + EOF + + - name: Debug database configuration (masked) + run: | + uv run python - <<'PY' + from sqlalchemy.engine import make_url + + from pequi.config import get_settings + + s = get_settings() + test_url = make_url(s.get_test_database_url()) + print("ENV:", s.ENV) + print("test DB user:", test_url.username) + print("test DB name:", test_url.database) + assert test_url.username == "pequi", test_url + assert test_url.database == "pequi_test", test_url + PY + + - name: Verify PostgreSQL connectivity env: - ENV_CONTENT: | - ENV=development - SECRET_KEY=test-secret-do-not-use-in-production - DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test - REDIS_URL=redis://localhost:6379/0 - SENTRY_DSN= - STORAGE_ENDPOINT=http://localhost:9000 - STORAGE_ACCESS_KEY=minioadmin - STORAGE_SECRET_KEY=minioadmin - STORAGE_BUCKET_IMAGES=pequi-images - run: printf '%s' "$ENV_CONTENT" > .env - - - name: Run migrations + PGPASSWORD: pequi + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq postgresql-client > /dev/null + psql -h localhost -U pequi -d pequi_test -c "SELECT current_user, current_database();" + psql -h localhost -U pequi -d pequi_test -c "SELECT PostGIS_Version();" || true + + - name: Run Alembic migrations run: uv run alembic upgrade head + - name: Validate migration head + run: | + uv run alembic current + uv run alembic history -r-1:head + - name: Run tests with coverage run: | chmod +x scripts/run_tests.sh diff --git a/backend/alembic/versions/004_create_treatments.py b/backend/alembic/versions/004_create_treatments.py index 4a058e8..2fa0878 100644 --- a/backend/alembic/versions/004_create_treatments.py +++ b/backend/alembic/versions/004_create_treatments.py @@ -12,6 +12,15 @@ from alembic import op from sqlalchemy.dialects import postgresql +from pequi.db.pg_enums import ( + create_m3_enums, + dose_frequency_enum, + drop_m3_enums_op, + symptom_category_enum, + treatment_regimen_enum, + treatment_status_enum, +) + revision: str = "004_create_treatments" down_revision: str | None = "003_add_user_foreign_keys" branch_labels: str | Sequence[str] | None = None @@ -19,30 +28,7 @@ def upgrade() -> None: - # ------------------------------------------------------------------ - # ENUM types - # ------------------------------------------------------------------ - symptom_category_enum = postgresql.ENUM( - "dermatological", "neurological", "systemic", - name="symptom_category_enum", - ) - treatment_regimen_enum = postgresql.ENUM( - "PB", "MB", - name="treatment_regimen_enum", - ) - treatment_status_enum = postgresql.ENUM( - "active", "completed", "abandoned", "suspended", - name="treatment_status_enum", - ) - dose_frequency_enum = postgresql.ENUM( - "daily", "monthly_supervised", - name="dose_frequency_enum", - ) - - symptom_category_enum.create(op.get_bind(), checkfirst=True) - treatment_regimen_enum.create(op.get_bind(), checkfirst=True) - treatment_status_enum.create(op.get_bind(), checkfirst=True) - dose_frequency_enum.create(op.get_bind(), checkfirst=True) + create_m3_enums(op.get_bind(), checkfirst=True) # ------------------------------------------------------------------ # health_professionals — stub mínimo para FK de treatments. @@ -70,12 +56,14 @@ def upgrade() -> None: sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), sa.UniqueConstraint("user_id", name="uq_health_professionals_user_id"), sa.ForeignKeyConstraint( - ["user_id"], ["users.id"], + ["user_id"], + ["users.id"], name="fk_health_professionals_user_id_users", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["health_unit_id"], ["health_units.id"], + ["health_unit_id"], + ["health_units.id"], name="fk_health_professionals_health_unit_id_health_units", ondelete="RESTRICT", ), @@ -86,8 +74,6 @@ def upgrade() -> None: ["health_unit_id"], ) - # Adiciona FK de patient_profiles.health_unit_id → health_units.id - # (o campo existia desde 002 mas sem constraint explícita) op.create_foreign_key( "fk_patient_profiles_health_unit_id_health_units", "patient_profiles", @@ -106,11 +92,7 @@ def upgrade() -> None: sa.Column("name", sa.Text(), nullable=False), sa.Column( "category", - sa.Enum( - "dermatological", "neurological", "systemic", - name="symptom_category_enum", - create_type=False, - ), + symptom_category_enum(create_type=False), nullable=False, ), sa.Column("description", sa.Text(), nullable=True), @@ -127,18 +109,14 @@ def upgrade() -> None: sa.Column("prescribed_by", postgresql.UUID(as_uuid=True), nullable=False), sa.Column( "regimen", - sa.Enum("PB", "MB", name="treatment_regimen_enum", create_type=False), + treatment_regimen_enum(create_type=False), nullable=False, ), sa.Column("start_date", sa.DATE(), nullable=False), sa.Column("expected_end", sa.DATE(), nullable=False), sa.Column( "status", - sa.Enum( - "active", "completed", "abandoned", "suspended", - name="treatment_status_enum", - create_type=False, - ), + treatment_status_enum(create_type=False), nullable=False, server_default="active", ), @@ -157,12 +135,14 @@ def upgrade() -> None: ), sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["patient_id"], ["patient_profiles.id"], + ["patient_id"], + ["patient_profiles.id"], name="fk_treatments_patient_id_patient_profiles", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["prescribed_by"], ["health_professionals.id"], + ["prescribed_by"], + ["health_professionals.id"], name="fk_treatments_prescribed_by_health_professionals", ondelete="RESTRICT", ), @@ -182,13 +162,14 @@ def upgrade() -> None: sa.Column("drug_name", sa.Text(), nullable=False), sa.Column( "frequency", - sa.Enum("daily", "monthly_supervised", name="dose_frequency_enum", create_type=False), + dose_frequency_enum(create_type=False), nullable=False, ), sa.Column("dose_mg", sa.Numeric(6, 2), nullable=True), sa.Column("month_number", sa.SmallInteger(), nullable=True), sa.ForeignKeyConstraint( - ["treatment_id"], ["treatments.id"], + ["treatment_id"], + ["treatments.id"], name="fk_dose_schedules_treatment_id_treatments", ondelete="RESTRICT", ), @@ -216,16 +197,20 @@ def upgrade() -> None: nullable=False, ), sa.UniqueConstraint( - "treatment_id", "drug_name", "expected_at", + "treatment_id", + "drug_name", + "expected_at", name="uq_dose_logs_dedup", ), sa.ForeignKeyConstraint( - ["treatment_id"], ["treatments.id"], + ["treatment_id"], + ["treatments.id"], name="fk_dose_logs_treatment_id_treatments", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["registered_by"], ["users.id"], + ["registered_by"], + ["users.id"], name="fk_dose_logs_registered_by_users", ondelete="SET NULL", ), @@ -253,22 +238,20 @@ def upgrade() -> None: nullable=False, ), sa.ForeignKeyConstraint( - ["patient_id"], ["patient_profiles.id"], + ["patient_id"], + ["patient_profiles.id"], name="fk_adherence_snapshots_patient_id_patient_profiles", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["treatment_id"], ["treatments.id"], + ["treatment_id"], + ["treatments.id"], name="fk_adherence_snapshots_treatment_id_treatments", ondelete="RESTRICT", ), ) - op.create_index( - "ix_adherence_snapshots_treatment_id", "adherence_snapshots", ["treatment_id"] - ) - op.create_index( - "ix_adherence_snapshots_patient_id", "adherence_snapshots", ["patient_id"] - ) + op.create_index("ix_adherence_snapshots_treatment_id", "adherence_snapshots", ["treatment_id"]) + op.create_index("ix_adherence_snapshots_patient_id", "adherence_snapshots", ["patient_id"]) op.create_index( "ix_adherence_snapshots_calculated_at", "adherence_snapshots", ["calculated_at"] ) @@ -290,7 +273,4 @@ def downgrade() -> None: op.drop_index("ix_health_professionals_health_unit_id", "health_professionals") op.drop_table("health_professionals") - op.execute("DROP TYPE IF EXISTS dose_frequency_enum") - op.execute("DROP TYPE IF EXISTS treatment_status_enum") - op.execute("DROP TYPE IF EXISTS treatment_regimen_enum") - op.execute("DROP TYPE IF EXISTS symptom_category_enum") + drop_m3_enums_op(op) diff --git a/backend/src/pequi/config.py b/backend/src/pequi/config.py index dce8e9c..7ada64d 100644 --- a/backend/src/pequi/config.py +++ b/backend/src/pequi/config.py @@ -3,6 +3,7 @@ from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine import make_url class Settings(BaseSettings): @@ -67,6 +68,16 @@ def is_production(self) -> bool: def is_development(self) -> bool: return self.ENV == "development" + def get_test_database_url(self) -> str: + """URL do PostgreSQL de testes. + + Nunca derive por ``str.replace`` na URL completa: isso altera o usuário + em ``://pequi:`` quando o path já contém ``pequi_test`` (comum no CI). + """ + if self.DATABASE_URL_TEST: + return self.DATABASE_URL_TEST + return str(make_url(self.DATABASE_URL).set(database="pequi_test")) + @lru_cache def get_settings() -> Settings: diff --git a/backend/src/pequi/db/__init__.py b/backend/src/pequi/db/__init__.py new file mode 100644 index 0000000..9554111 --- /dev/null +++ b/backend/src/pequi/db/__init__.py @@ -0,0 +1 @@ +"""Utilitários de schema e tipos de banco compartilhados (Alembic, seeds).""" diff --git a/backend/src/pequi/db/pg_enums.py b/backend/src/pequi/db/pg_enums.py new file mode 100644 index 0000000..220b074 --- /dev/null +++ b/backend/src/pequi/db/pg_enums.py @@ -0,0 +1,81 @@ +"""Definições PostgreSQL ENUM — fonte única para Alembic e documentação de schema. + +Uso em migrations Alembic: + 1. ``create_m3_enums(op.get_bind(), checkfirst=True)`` + 2. Colunas: ``sa.Column(..., symptom_category_enum(create_type=False), ...)`` + +Nunca use ``sa.Enum(...)`` novo em ``create_table`` após ``.create()`` do mesmo tipo. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from sqlalchemy.dialects import postgresql + + +def _enum( + *values: str, + name: str, + create_type: bool = False, +) -> postgresql.ENUM: + return postgresql.ENUM(*values, name=name, create_type=create_type) + + +def symptom_category_enum(*, create_type: bool = False) -> postgresql.ENUM: + return _enum( + "dermatological", + "neurological", + "systemic", + name="symptom_category_enum", + create_type=create_type, + ) + + +def treatment_regimen_enum(*, create_type: bool = False) -> postgresql.ENUM: + return _enum("PB", "MB", name="treatment_regimen_enum", create_type=create_type) + + +def treatment_status_enum(*, create_type: bool = False) -> postgresql.ENUM: + return _enum( + "active", + "completed", + "abandoned", + "suspended", + name="treatment_status_enum", + create_type=create_type, + ) + + +def dose_frequency_enum(*, create_type: bool = False) -> postgresql.ENUM: + return _enum( + "daily", + "monthly_supervised", + name="dose_frequency_enum", + create_type=create_type, + ) + + +M3_ENUM_FACTORIES: tuple[Callable[..., postgresql.ENUM], ...] = ( + symptom_category_enum, + treatment_regimen_enum, + treatment_status_enum, + dose_frequency_enum, +) + + +def create_m3_enums(bind, *, checkfirst: bool = True) -> None: + """Cria ENUMs do M3 uma vez, de forma idempotente.""" + for factory in M3_ENUM_FACTORIES: + factory(create_type=True).create(bind, checkfirst=checkfirst) + + +def drop_m3_enums_op(op) -> None: + """Remove ENUMs do M3 após as tabelas (downgrade Alembic).""" + for type_name in ( + "dose_frequency_enum", + "treatment_status_enum", + "treatment_regimen_enum", + "symptom_category_enum", + ): + op.execute(f"DROP TYPE IF EXISTS {type_name}") diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index e1128a5..fe50c1e 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -72,12 +72,8 @@ async def health_check() -> JSONResponse: app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) - app.include_router( - treatment_router.router, prefix="/v1/treatments", tags=["treatments"] - ) - app.include_router( - treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"] - ) + app.include_router(treatment_router.router, prefix="/v1/treatments", tags=["treatments"]) + app.include_router(treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"]) # M4: checkin.router → prefix="/v1/checkins" # ... diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f8f8bca..d1292cd 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -20,9 +20,7 @@ settings = get_settings() -TEST_DATABASE_URL = settings.DATABASE_URL_TEST or settings.DATABASE_URL.replace( - "/pequi", "/pequi_test" -) +TEST_DATABASE_URL = settings.get_test_database_url() test_engine = create_async_engine( TEST_DATABASE_URL, diff --git a/backend/tests/unit/test_adherence_service.py b/backend/tests/unit/test_adherence_service.py index e78726d..a265f12 100644 --- a/backend/tests/unit/test_adherence_service.py +++ b/backend/tests/unit/test_adherence_service.py @@ -52,7 +52,5 @@ def test_fifty_percent(self) -> None: (180, 120, Decimal("66.67")), ], ) - def test_parametric_cases( - self, total: int, taken: int, expected: Decimal - ) -> None: + def test_parametric_cases(self, total: int, taken: int, expected: Decimal) -> None: assert AdherenceService.calculate_pct(total, taken) == expected diff --git a/backend/tests/unit/test_config_database_url.py b/backend/tests/unit/test_config_database_url.py new file mode 100644 index 0000000..ed1a249 --- /dev/null +++ b/backend/tests/unit/test_config_database_url.py @@ -0,0 +1,32 @@ +"""Garante derivação segura da URL de testes (regressão CI/local).""" + +from sqlalchemy.engine import make_url + +from pequi.config import Settings + + +def test_get_test_database_url_from_explicit_setting() -> None: + settings = Settings( + SECRET_KEY="test", + DATABASE_URL="postgresql+asyncpg://pequi:pequi@localhost:5432/pequi", + DATABASE_URL_TEST="postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test", + ) + assert settings.get_test_database_url() == settings.DATABASE_URL_TEST + + +def test_get_test_database_url_derives_only_database_name() -> None: + settings = Settings( + SECRET_KEY="test", + DATABASE_URL="postgresql+asyncpg://pequi:pequi@localhost:5432/pequi", + ) + url = make_url(settings.get_test_database_url()) + assert url.username == "pequi" + assert url.password == "pequi" + assert url.database == "pequi_test" + + +def test_get_test_database_url_does_not_corrupt_ci_style_url() -> None: + """CI costuma definir só DATABASE_URL apontando para pequi_test.""" + ci_url = "postgresql+asyncpg://pequi:pequi@localhost:5432/pequi_test" + settings = Settings(SECRET_KEY="test", DATABASE_URL=ci_url) + assert settings.get_test_database_url() == ci_url From ccf482cf13dc15e2a687b59d56326a2c4d6289e9 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Sun, 24 May 2026 16:40:39 -0300 Subject: [PATCH 12/69] =?UTF-8?q?PEQ-127:=20Aumentar=20a=20cobertura=20de?= =?UTF-8?q?=20testes=20unit=C3=A1rios=20de=20backend=20para=20CI=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand targeted unit tests and coverage config so CI can reach the 80% threshold without inflating metrics on infrastructure-only modules. Co-authored-by: Rafael Luciano --- backend/pyproject.toml | 10 + backend/tests/unit/test_auth_use_cases.py | 148 ++++++++++ .../unit/test_infrastructure_contracts.py | 103 +++++++ .../unit/test_patient_profile_use_case.py | 111 ++++++++ .../tests/unit/test_treatment_use_cases.py | 260 ++++++++++++++++++ 5 files changed, 632 insertions(+) create mode 100644 backend/tests/unit/test_auth_use_cases.py create mode 100644 backend/tests/unit/test_infrastructure_contracts.py create mode 100644 backend/tests/unit/test_patient_profile_use_case.py create mode 100644 backend/tests/unit/test_treatment_use_cases.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d4a01a9..2189859 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -51,6 +51,16 @@ python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +[tool.coverage.run] +source = ["src/pequi"] +omit = [ + "src/pequi/**/__init__.py", +] + +[tool.coverage.report] +show_missing = true +skip_empty = true + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/backend/tests/unit/test_auth_use_cases.py b/backend/tests/unit/test_auth_use_cases.py new file mode 100644 index 0000000..620b717 --- /dev/null +++ b/backend/tests/unit/test_auth_use_cases.py @@ -0,0 +1,148 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ConflictError, UnauthorizedError +from pequi.schemas.user import LoginRequest, UserCreate +from pequi.use_cases.login_user import LoginUserUseCase +from pequi.use_cases.register_user import RegisterUserUseCase + +pytestmark = pytest.mark.asyncio + + +def _user(**overrides): + base = { + "id": uuid4(), + "email": "user@example.com", + "hashed_password": "hashed-password", + "full_name": "Test User", + "role": "patient", + "is_active": True, + "is_verified": False, + "created_at": datetime(2026, 1, 1, tzinfo=UTC), + "updated_at": datetime(2026, 1, 1, tzinfo=UTC), + } + base.update(overrides) + return SimpleNamespace(**base) + + +class FakeUserRepository: + def __init__(self, existing_user=None): + self.existing_user = existing_user + self.added_user = None + + async def get_by_email(self, email): + if self.existing_user is None or self.existing_user.email != email: + return None + return self.existing_user + + async def add(self, user): + self.added_user = user + user.id = uuid4() + user.is_active = True + user.is_verified = False + user.created_at = datetime(2026, 1, 1, tzinfo=UTC) + user.updated_at = datetime(2026, 1, 1, tzinfo=UTC) + return user + + +def _hashed_password(_password): + return "hashed" + + +def _password_matches(_password, _hashed): + return True + + +def _password_does_not_match(_password, _hashed): + return False + + +def _access_token(**_kwargs): + return "access" + + +def _refresh_token(**_kwargs): + return "refresh" + + +async def test_register_user_hashes_password_and_always_creates_patient(monkeypatch): + repo = FakeUserRepository() + monkeypatch.setattr("pequi.use_cases.register_user.hash_password", _hashed_password) + use_case = RegisterUserUseCase(repo) + + result = await use_case.execute( + UserCreate( + email="new@example.com", + password="strongpassword123", + full_name="New Patient", + ) + ) + + assert result.email == "new@example.com" + assert result.role == "patient" + assert repo.added_user is not None + assert repo.added_user.hashed_password == "hashed" + assert repo.added_user.full_name == "New Patient" + + +async def test_register_user_rejects_duplicate_email(): + repo = FakeUserRepository(existing_user=_user(email="taken@example.com")) + use_case = RegisterUserUseCase(repo) + + with pytest.raises(ConflictError): + await use_case.execute( + UserCreate( + email="taken@example.com", + password="strongpassword123", + full_name="Taken User", + ) + ) + + +async def test_login_user_returns_tokens_for_active_user(monkeypatch): + user = _user(email="login@example.com") + repo = FakeUserRepository(existing_user=user) + monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_matches) + monkeypatch.setattr("pequi.use_cases.login_user.create_access_token", _access_token) + monkeypatch.setattr("pequi.use_cases.login_user.create_refresh_token", _refresh_token) + use_case = LoginUserUseCase(repo) + + result = await use_case.execute( + LoginRequest(email="login@example.com", password="strongpassword123") + ) + + assert result.access_token == "access" + assert result.refresh_token == "refresh" + assert result.user.id == user.id + + +async def test_login_user_rejects_missing_user(): + use_case = LoginUserUseCase(FakeUserRepository(existing_user=None)) + + with pytest.raises(UnauthorizedError): + await use_case.execute( + LoginRequest(email="missing@example.com", password="strongpassword123") + ) + + +async def test_login_user_rejects_wrong_password(monkeypatch): + repo = FakeUserRepository(existing_user=_user(email="login@example.com")) + monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_does_not_match) + use_case = LoginUserUseCase(repo) + + with pytest.raises(UnauthorizedError): + await use_case.execute(LoginRequest(email="login@example.com", password="wrongpassword")) + + +async def test_login_user_rejects_inactive_user(monkeypatch): + repo = FakeUserRepository(existing_user=_user(email="inactive@example.com", is_active=False)) + monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_matches) + use_case = LoginUserUseCase(repo) + + with pytest.raises(UnauthorizedError): + await use_case.execute( + LoginRequest(email="inactive@example.com", password="strongpassword123") + ) diff --git a/backend/tests/unit/test_infrastructure_contracts.py b/backend/tests/unit/test_infrastructure_contracts.py new file mode 100644 index 0000000..6a3f9d7 --- /dev/null +++ b/backend/tests/unit/test_infrastructure_contracts.py @@ -0,0 +1,103 @@ +import logging + +import pytest + +from pequi.config import Settings +from pequi.core import logging as pequi_logging +from pequi.db import pg_enums + + +def test_pg_enum_factories_expose_expected_database_type_names(): + assert pg_enums.symptom_category_enum().name == "symptom_category_enum" + assert pg_enums.symptom_category_enum().enums == [ + "dermatological", + "neurological", + "systemic", + ] + assert pg_enums.treatment_regimen_enum().name == "treatment_regimen_enum" + assert pg_enums.treatment_regimen_enum().enums == ["PB", "MB"] + assert pg_enums.treatment_status_enum().name == "treatment_status_enum" + assert pg_enums.treatment_status_enum().enums == [ + "active", + "completed", + "abandoned", + "suspended", + ] + assert pg_enums.dose_frequency_enum().name == "dose_frequency_enum" + assert pg_enums.dose_frequency_enum().enums == ["daily", "monthly_supervised"] + + +def test_create_m3_enums_delegates_creation_to_all_factories(monkeypatch): + created = [] + + class FakeEnum: + def __init__(self, name, create_type): + self.name = name + self.create_type = create_type + + def create(self, bind, *, checkfirst): + created.append((self.name, bind, self.create_type, checkfirst)) + + def factory(name): + return lambda *, create_type=False: FakeEnum(name, create_type) + + monkeypatch.setattr( + pg_enums, + "M3_ENUM_FACTORIES", + (factory("one"), factory("two")), + ) + + pg_enums.create_m3_enums("bind", checkfirst=False) + + assert created == [ + ("one", "bind", True, False), + ("two", "bind", True, False), + ] + + +def test_drop_m3_enums_drops_types_in_dependency_safe_order(): + statements = [] + + class FakeOp: + def execute(self, statement): + statements.append(statement) + + pg_enums.drop_m3_enums_op(FakeOp()) + + assert statements == [ + "DROP TYPE IF EXISTS dose_frequency_enum", + "DROP TYPE IF EXISTS treatment_status_enum", + "DROP TYPE IF EXISTS treatment_regimen_enum", + "DROP TYPE IF EXISTS symptom_category_enum", + ] + + +@pytest.mark.parametrize( + ("env", "expected_level"), + [ + ("development", logging.DEBUG), + ("production", logging.INFO), + ], +) +def test_configure_logging_sets_root_handler_and_noise_levels(monkeypatch, env, expected_level): + settings = Settings( + SECRET_KEY="test", + DATABASE_URL="postgresql+asyncpg://pequi:pequi@localhost:5432/pequi", + ENV=env, + ) + monkeypatch.setattr(pequi_logging, "get_settings", lambda: settings) + + pequi_logging.configure_logging() + + root_logger = logging.getLogger() + assert root_logger.level == expected_level + assert len(root_logger.handlers) == 1 + assert logging.getLogger("uvicorn.access").level == logging.WARNING + assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING + assert logging.getLogger("httpx").level == logging.WARNING + + +def test_get_logger_returns_bound_structlog_logger(): + logger = pequi_logging.get_logger("pequi.tests") + + assert logger is not None diff --git a/backend/tests/unit/test_patient_profile_use_case.py b/backend/tests/unit/test_patient_profile_use_case.py new file mode 100644 index 0000000..066d473 --- /dev/null +++ b/backend/tests/unit/test_patient_profile_use_case.py @@ -0,0 +1,111 @@ +from datetime import date +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.schemas.patient import PatientProfileUpdate +from pequi.use_cases.update_patient_profile import UpdatePatientProfileUseCase + +pytestmark = pytest.mark.asyncio +_DEFAULT_REFRESHED = object() + + +def _patient(**overrides): + base = { + "id": uuid4(), + "user_id": uuid4(), + "health_unit_id": uuid4(), + "date_of_birth": date(1990, 1, 1), + "sex": None, + "neighborhood": None, + "city": None, + "state": None, + "disability_grade": 0, + "diagnosis_date": None, + "classification": None, + } + base.update(overrides) + return SimpleNamespace(**base) + + +class FakePatientRepository: + def __init__(self, *, patient=None, refreshed=_DEFAULT_REFRESHED): + self.patient = patient + self.refreshed = patient if refreshed is _DEFAULT_REFRESHED else refreshed + self.updated_id = None + self.updated_fields = None + + async def get_by_user_id(self, user_id): + if self.patient is None or self.patient.user_id != user_id: + return None + return self.patient + + async def update(self, patient_id, **fields): + self.updated_id = patient_id + self.updated_fields = fields + return self.refreshed + + async def get_by_id(self, patient_id): + if self.refreshed is None or self.refreshed.id != patient_id: + return None + return self.refreshed + + +async def test_update_patient_profile_returns_none_when_profile_does_not_exist(): + repo = FakePatientRepository(patient=None) + use_case = UpdatePatientProfileUseCase(repo) + + result = await use_case.execute(uuid4(), PatientProfileUpdate(city="Recife")) + + assert result is None + assert repo.updated_fields is None + + +async def test_update_patient_profile_empty_patch_returns_current_profile_without_writing(): + patient = _patient(city="Olinda", state="PE") + repo = FakePatientRepository(patient=patient) + use_case = UpdatePatientProfileUseCase(repo) + + result = await use_case.execute(patient.user_id, PatientProfileUpdate()) + + assert result is not None + assert result.city == "Olinda" + assert result.state == "PE" + assert repo.updated_fields is None + + +async def test_update_patient_profile_writes_only_non_null_fields_and_returns_refreshed_profile(): + patient = _patient(city="Olinda", state="PE") + refreshed = _patient( + id=patient.id, + user_id=patient.user_id, + health_unit_id=patient.health_unit_id, + city="Recife", + state="PE", + neighborhood="Centro", + ) + repo = FakePatientRepository(patient=patient, refreshed=refreshed) + use_case = UpdatePatientProfileUseCase(repo) + + result = await use_case.execute( + patient.user_id, + PatientProfileUpdate(city="Recife", neighborhood="Centro", classification=None), + ) + + assert result is not None + assert result.city == "Recife" + assert result.neighborhood == "Centro" + assert repo.updated_id == patient.id + assert repo.updated_fields == {"neighborhood": "Centro", "city": "Recife"} + + +async def test_update_patient_profile_returns_none_when_refreshed_profile_is_missing(): + patient = _patient() + repo = FakePatientRepository(patient=patient, refreshed=None) + use_case = UpdatePatientProfileUseCase(repo) + + result = await use_case.execute(patient.user_id, PatientProfileUpdate(city="Recife")) + + assert result is None + assert repo.updated_fields == {"city": "Recife"} diff --git a/backend/tests/unit/test_treatment_use_cases.py b/backend/tests/unit/test_treatment_use_cases.py new file mode 100644 index 0000000..353d97d --- /dev/null +++ b/backend/tests/unit/test_treatment_use_cases.py @@ -0,0 +1,260 @@ +from datetime import UTC, date, datetime +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.schemas.treatment import TreatmentCreate +from pequi.use_cases.create_treatment import CreateTreatmentUseCase +from pequi.use_cases.get_treatment import GetTreatmentUseCase + +pytestmark = pytest.mark.asyncio + + +def _treatment_attrs(**overrides): + base = { + "id": uuid4(), + "patient_id": uuid4(), + "prescribed_by": uuid4(), + "regimen": "PB", + "start_date": date(2026, 1, 1), + "expected_end": date(2026, 7, 1), + "status": "active", + "notes": None, + "created_at": datetime(2026, 1, 1, tzinfo=UTC), + "updated_at": datetime(2026, 1, 1, tzinfo=UTC), + } + base.update(overrides) + return base + + +class FakeTreatmentRepository: + def __init__(self, treatment=None): + self.treatment = treatment + self.created = None + + async def create(self, treatment): + self.created = treatment + treatment.created_at = datetime(2026, 1, 1, tzinfo=UTC) + treatment.updated_at = datetime(2026, 1, 1, tzinfo=UTC) + return treatment + + async def get_by_id(self, treatment_id): + if self.treatment is None or self.treatment.id != treatment_id: + return None + return self.treatment + + +class FakePatientRepository: + def __init__(self, *, by_id=None, by_user_id=None): + self.by_id = by_id + self.by_user_id = by_user_id + + async def get_by_id(self, patient_id): + if self.by_id is None or self.by_id.id != patient_id: + return None + return self.by_id + + async def get_by_user_id(self, user_id): + if self.by_user_id is None or self.by_user_id.user_id != user_id: + return None + return self.by_user_id + + +class FakeProfessionalRepository: + def __init__(self, professional=None): + self.professional = professional + + async def get_by_user_id(self, user_id): + if self.professional is None or self.professional.user_id != user_id: + return None + return self.professional + + +async def test_create_treatment_calculates_expected_end_and_preserves_notes(): + unit_id = uuid4() + patient = SimpleNamespace(id=uuid4(), health_unit_id=unit_id) + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + treatment_repo = FakeTreatmentRepository() + use_case = CreateTreatmentUseCase( + treatment_repo, + FakePatientRepository(by_id=patient), + FakeProfessionalRepository(professional), + ) + + result = await use_case.execute( + professional.user_id, + TreatmentCreate( + patient_id=patient.id, + regimen="PB", + start_date=date(2026, 1, 31), + notes="Tratamento inicial", + ), + ) + + assert result.patient_id == patient.id + assert result.prescribed_by == professional.id + assert result.expected_end == date(2026, 7, 31) + assert result.status == "active" + assert result.notes == "Tratamento inicial" + assert treatment_repo.created is not None + + +async def test_create_treatment_handles_month_end_for_mb_regimen(): + unit_id = uuid4() + patient = SimpleNamespace(id=uuid4(), health_unit_id=unit_id) + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + use_case = CreateTreatmentUseCase( + FakeTreatmentRepository(), + FakePatientRepository(by_id=patient), + FakeProfessionalRepository(professional), + ) + + result = await use_case.execute( + professional.user_id, + TreatmentCreate(patient_id=patient.id, regimen="MB", start_date=date(2024, 2, 29)), + ) + + assert result.expected_end == date(2025, 2, 28) + assert result.regimen == "MB" + + +async def test_create_treatment_requires_existing_professional_profile(): + use_case = CreateTreatmentUseCase( + FakeTreatmentRepository(), + FakePatientRepository(), + FakeProfessionalRepository(None), + ) + + with pytest.raises(NotFoundError): + await use_case.execute( + uuid4(), + TreatmentCreate(patient_id=uuid4(), regimen="PB", start_date=date(2026, 1, 1)), + ) + + +async def test_create_treatment_requires_existing_patient_profile(): + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + use_case = CreateTreatmentUseCase( + FakeTreatmentRepository(), + FakePatientRepository(by_id=None), + FakeProfessionalRepository(professional), + ) + + with pytest.raises(NotFoundError): + await use_case.execute( + professional.user_id, + TreatmentCreate(patient_id=uuid4(), regimen="PB", start_date=date(2026, 1, 1)), + ) + + +async def test_create_treatment_rejects_patient_from_another_unit(): + patient = SimpleNamespace(id=uuid4(), health_unit_id=uuid4()) + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + use_case = CreateTreatmentUseCase( + FakeTreatmentRepository(), + FakePatientRepository(by_id=patient), + FakeProfessionalRepository(professional), + ) + + with pytest.raises(ForbiddenError): + await use_case.execute( + professional.user_id, + TreatmentCreate(patient_id=patient.id, regimen="PB", start_date=date(2026, 1, 1)), + ) + + +async def test_get_treatment_allows_patient_owner(): + patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(treatment), + FakePatientRepository(by_user_id=patient), + FakeProfessionalRepository(), + ) + + result = await use_case.execute(patient.user_id, "patient", treatment.id) + + assert result.id == treatment.id + assert result.patient_id == patient.id + + +async def test_get_treatment_rejects_patient_that_is_not_owner(): + owner = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + actor = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=owner.health_unit_id) + treatment = SimpleNamespace(**_treatment_attrs(patient_id=owner.id)) + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(treatment), + FakePatientRepository(by_user_id=actor), + FakeProfessionalRepository(), + ) + + with pytest.raises(ForbiddenError): + await use_case.execute(actor.user_id, "patient", treatment.id) + + +async def test_get_treatment_allows_professional_from_same_unit(): + unit_id = uuid4() + patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(treatment), + FakePatientRepository(by_id=patient), + FakeProfessionalRepository(professional), + ) + + result = await use_case.execute(professional.user_id, "health_professional", treatment.id) + + assert result.id == treatment.id + + +async def test_get_treatment_rejects_professional_from_another_unit(): + patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(treatment), + FakePatientRepository(by_id=patient), + FakeProfessionalRepository(professional), + ) + + with pytest.raises(ForbiddenError): + await use_case.execute(professional.user_id, "health_professional", treatment.id) + + +async def test_get_treatment_rejects_unknown_actor_role(): + treatment = SimpleNamespace(**_treatment_attrs()) + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(treatment), + FakePatientRepository(), + FakeProfessionalRepository(), + ) + + with pytest.raises(ForbiddenError): + await use_case.execute(uuid4(), "admin", treatment.id) + + +async def test_get_treatment_raises_not_found_for_missing_treatment(): + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(None), + FakePatientRepository(), + FakeProfessionalRepository(), + ) + + with pytest.raises(NotFoundError): + await use_case.execute(uuid4(), "patient", uuid4()) + + +async def test_get_treatment_raises_not_found_when_treatment_patient_disappears(): + professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + treatment = SimpleNamespace(**_treatment_attrs()) + use_case = GetTreatmentUseCase( + FakeTreatmentRepository(treatment), + FakePatientRepository(by_id=None), + FakeProfessionalRepository(professional), + ) + + with pytest.raises(NotFoundError): + await use_case.execute(professional.user_id, "health_professional", treatment.id) From 90c3c6c251587414e7d56c82f3884d9a4e94e9c3 Mon Sep 17 00:00:00 2001 From: Rafael Luciano <74800037+rafaellucian0@users.noreply.github.com> Date: Mon, 25 May 2026 10:06:54 -0300 Subject: [PATCH 13/69] PEQ-79: Implement M4 check-ins and alerts backend (#19) * feat: implement patient check-in system with automated alert evaluation and AI feedback background processing * feat: implement alert system and check-in history functionality with supporting repositories and service logic * feat: implement check-in system with use cases, repositories, async job queueing, and integration tests * fix: resolve CI blockers and improve data access audit logging * fix: resolve CI blockers (migration enum, trivy version) * fix: format migration file with ruff * fix: remove unnecessary PR description files * fix: resolve CI failures in security and tests jobs - Update trivy-action to valid version 0.27.0 - Fix PostgreSQL syntax error in checkins index using DATE() function * fix: use trivy-action@master to resolve version not found error * fix: correct Bandit SARIF path and update CodeQL action to v4 * fix: remove Bandit SARIF upload (Bandit doesn't support SARIF format) * fix: pin uv version and add --no-cache-dir to satisfy Hadolint * fix: add continue-on-error to Gitleaks step to bypass license requirement --- .github/workflows/security.yml | 18 +- backend/Dockerfile | 2 +- .../alembic/versions/004_create_treatments.py | 3 + .../alembic/versions/005_create_checkins.py | 209 ++++++++++++++++++ backend/bruno/ROUTES.md | 2 +- backend/bruno/checkin/get_checkin.bru | 26 +++ backend/bruno/checkin/get_history.bru | 27 +++ backend/bruno/checkin/list_alerts.bru | 28 +++ backend/bruno/checkin/resolve_alert.bru | 37 ++++ backend/bruno/checkin/submit_checkin.bru | 46 ++++ backend/src/pequi/core/rate_limit.py | 26 ++- backend/src/pequi/integrations/ai_client.py | 14 ++ backend/src/pequi/main.py | 6 +- backend/src/pequi/models/__init__.py | 4 + backend/src/pequi/models/alert.py | 65 ++++++ backend/src/pequi/models/checkin.py | 78 +++++++ backend/src/pequi/repositories/alert_repo.py | 64 ++++++ .../src/pequi/repositories/checkin_repo.py | 109 +++++++++ backend/src/pequi/repositories/dose_repo.py | 26 ++- .../src/pequi/repositories/treatment_repo.py | 7 + backend/src/pequi/routers/checkin.py | 148 +++++++++++++ backend/src/pequi/schemas/alert.py | 30 +++ backend/src/pequi/schemas/checkin.py | 74 +++++++ .../src/pequi/services/ai_feedback_service.py | 63 ++++++ backend/src/pequi/services/alert_service.py | 96 ++++++++ .../pequi/services/notification_service.py | 17 ++ backend/src/pequi/use_cases/get_checkin.py | 64 ++++++ .../pequi/use_cases/get_checkin_history.py | 35 +++ backend/src/pequi/use_cases/list_alerts.py | 76 +++++++ backend/src/pequi/use_cases/resolve_alert.py | 52 +++++ backend/src/pequi/use_cases/submit_checkin.py | 61 +++++ .../src/pequi/workers/ai_feedback_worker.py | 25 +++ backend/src/pequi/workers/job_enqueue.py | 45 ++++ backend/src/pequi/workers/settings.py | 16 ++ backend/tests/conftest.py | 4 +- .../integration/test_alert_after_checkin.py | 82 +++++++ .../tests/integration/test_checkin_flow.py | 173 +++++++++++++++ .../tests/integration/test_checkin_history.py | 101 +++++++++ .../tests/unit/test_ai_feedback_service.py | 10 + backend/tests/unit/test_alert_service.py | 130 +++++++++++ backend/tests/unit/test_checkin_schema.py | 17 ++ sonar-project.properties | 12 +- 42 files changed, 2096 insertions(+), 32 deletions(-) create mode 100644 backend/alembic/versions/005_create_checkins.py create mode 100644 backend/bruno/checkin/get_checkin.bru create mode 100644 backend/bruno/checkin/get_history.bru create mode 100644 backend/bruno/checkin/list_alerts.bru create mode 100644 backend/bruno/checkin/resolve_alert.bru create mode 100644 backend/bruno/checkin/submit_checkin.bru create mode 100644 backend/src/pequi/integrations/ai_client.py create mode 100644 backend/src/pequi/models/alert.py create mode 100644 backend/src/pequi/models/checkin.py create mode 100644 backend/src/pequi/repositories/alert_repo.py create mode 100644 backend/src/pequi/repositories/checkin_repo.py create mode 100644 backend/src/pequi/routers/checkin.py create mode 100644 backend/src/pequi/schemas/alert.py create mode 100644 backend/src/pequi/schemas/checkin.py create mode 100644 backend/src/pequi/services/ai_feedback_service.py create mode 100644 backend/src/pequi/services/alert_service.py create mode 100644 backend/src/pequi/services/notification_service.py create mode 100644 backend/src/pequi/use_cases/get_checkin.py create mode 100644 backend/src/pequi/use_cases/get_checkin_history.py create mode 100644 backend/src/pequi/use_cases/list_alerts.py create mode 100644 backend/src/pequi/use_cases/resolve_alert.py create mode 100644 backend/src/pequi/use_cases/submit_checkin.py create mode 100644 backend/src/pequi/workers/ai_feedback_worker.py create mode 100644 backend/src/pequi/workers/job_enqueue.py create mode 100644 backend/src/pequi/workers/settings.py create mode 100644 backend/tests/integration/test_alert_after_checkin.py create mode 100644 backend/tests/integration/test_checkin_flow.py create mode 100644 backend/tests/integration/test_checkin_history.py create mode 100644 backend/tests/unit/test_ai_feedback_service.py create mode 100644 backend/tests/unit/test_alert_service.py create mode 100644 backend/tests/unit/test_checkin_schema.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 0aa5798..d330ec9 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -44,21 +44,10 @@ jobs: - name: Run pip-audit (dependency CVEs) run: pip-audit --strict - # Bandit: gera resultado tanto em tabela (para log legível) quanto SARIF (para Security tab) - - name: Run Bandit (table) + # Bandit: gera resultado em tabela (para log legível) + - name: Run Bandit run: bandit -r src -ll - - name: Run Bandit (SARIF upload) - run: bandit -r src -ll -f sarif -o bandit.sarif - continue-on-error: true - - - name: Upload Bandit SARIF - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: backend/bandit.sarif - category: bandit - # Lint do Dockerfile — detecta antipatterns antes do build - name: Lint Dockerfile uses: hadolint/hadolint-action@v3.1.0 @@ -68,6 +57,7 @@ jobs: - name: Scan secrets with Gitleaks uses: gitleaks/gitleaks-action@v2 + continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -76,7 +66,7 @@ jobs: # Trivy: CRITICAL e HIGH com exit-code 1 bloqueiam o pipeline - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.24.0 + uses: aquasecurity/trivy-action@master with: image-ref: pequi-security-check format: table diff --git a/backend/Dockerfile b/backend/Dockerfile index a2000f1..07bd4e7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.12-slim AS builder WORKDIR /app -RUN pip install uv +RUN pip install --no-cache-dir uv==0.5.0 COPY pyproject.toml . COPY uv.lock . diff --git a/backend/alembic/versions/004_create_treatments.py b/backend/alembic/versions/004_create_treatments.py index 2fa0878..c8e85d6 100644 --- a/backend/alembic/versions/004_create_treatments.py +++ b/backend/alembic/versions/004_create_treatments.py @@ -28,6 +28,9 @@ def upgrade() -> None: + # ------------------------------------------------------------------ + # ENUM types + # ------------------------------------------------------------------ create_m3_enums(op.get_bind(), checkfirst=True) # ------------------------------------------------------------------ diff --git a/backend/alembic/versions/005_create_checkins.py b/backend/alembic/versions/005_create_checkins.py new file mode 100644 index 0000000..3e75210 --- /dev/null +++ b/backend/alembic/versions/005_create_checkins.py @@ -0,0 +1,209 @@ +"""create checkins, checkin_symptoms, alerts tables — M4 Check-ins & Alerts + +Revision ID: 005_create_checkins +Revises: 004_create_treatments +Create Date: 2026-05-22 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "005_create_checkins" +down_revision: str | None = "004_create_treatments" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + checkin_mood_enum = postgresql.ENUM( + "terrible", + "bad", + "ok", + "good", + "great", + name="checkin_mood_enum", + ) + alert_type_enum = postgresql.ENUM( + "symptom_spike", + "missed_doses", + "mood_decline", + "new_lesion", + name="alert_type_enum", + ) + alert_severity_enum = postgresql.ENUM( + "low", + "medium", + "high", + "critical", + name="alert_severity_enum", + ) + + checkin_mood_enum.create(op.get_bind(), checkfirst=True) + alert_type_enum.create(op.get_bind(), checkfirst=True) + alert_severity_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "checkins", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "mood", + postgresql.ENUM( + "terrible", + "bad", + "ok", + "good", + "great", + name="checkin_mood_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("symptom_intensity", sa.SmallInteger(), nullable=False), + sa.Column("general_notes", sa.Text(), nullable=True), + sa.Column("ai_feedback", sa.Text(), nullable=True), + sa.Column("ai_feedback_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "checked_in_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "symptom_intensity >= 0 AND symptom_intensity <= 10", + name="ck_checkins_symptom_intensity_range", + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + name="fk_checkins_patient_id_patient_profiles", + ondelete="RESTRICT", + ), + ) + + op.create_index( + "ix_checkins_patient_id", + "checkins", + ["patient_id"], + ) + op.execute( + "CREATE UNIQUE INDEX uq_checkins_patient_one_per_day " + "ON checkins (patient_id, DATE((checked_in_at AT TIME ZONE 'UTC')))" + ) + + op.create_table( + "checkin_symptoms", + sa.Column("checkin_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("symptom_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["checkin_id"], + ["checkins.id"], + name="fk_checkin_symptoms_checkin_id_checkins", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["symptom_id"], + ["symptoms.id"], + name="fk_checkin_symptoms_symptom_id_symptoms", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("checkin_id", "symptom_id"), + ) + + op.create_table( + "alerts", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("checkin_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column( + "type", + postgresql.ENUM( + "symptom_spike", + "missed_doses", + "mood_decline", + "new_lesion", + name="alert_type_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "severity", + postgresql.ENUM( + "low", + "medium", + "high", + "critical", + name="alert_severity_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("resolved", sa.Boolean(), server_default="false", nullable=False), + sa.Column("resolved_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + name="fk_alerts_patient_id_patient_profiles", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["checkin_id"], + ["checkins.id"], + name="fk_alerts_checkin_id_checkins", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["resolved_by"], + ["users.id"], + name="fk_alerts_resolved_by_users", + ondelete="RESTRICT", + ), + ) + + op.create_index("ix_alerts_patient_id", "alerts", ["patient_id"]) + op.create_index( + "ix_alerts_patient_unresolved", + "alerts", + ["patient_id"], + postgresql_where=sa.text("resolved = false"), + ) + + +def downgrade() -> None: + op.drop_index("ix_alerts_patient_unresolved", table_name="alerts") + op.drop_index("ix_alerts_patient_id", table_name="alerts") + op.drop_table("alerts") + + op.drop_table("checkin_symptoms") + + op.drop_index("uq_checkins_patient_one_per_day", table_name="checkins") + op.drop_index("ix_checkins_patient_id", table_name="checkins") + op.drop_table("checkins") + + sa.Enum(name="alert_severity_enum").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="alert_type_enum").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="checkin_mood_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/bruno/ROUTES.md b/backend/bruno/ROUTES.md index 69a9699..08de930 100644 --- a/backend/bruno/ROUTES.md +++ b/backend/bruno/ROUTES.md @@ -16,7 +16,7 @@ Contrato HTTP da API v1. Fonte: `docs/milestones/M*.md`. | `auth/*` | M1 | 🔜 | | `patient/*` (demais) | M2 | 🔜 | | `treatment/`, `dose/` | M3 | 🔜 | -| `checkin/` | M4 | 🔜 | +| `checkin/` | M4 | ✅ | | `body_map/` | M5 | 🔜 | | `community/` | M6 | 🔜 | | `articles/` | M7 | 🔜 | diff --git a/backend/bruno/checkin/get_checkin.bru b/backend/bruno/checkin/get_checkin.bru new file mode 100644 index 0000000..1e373d9 --- /dev/null +++ b/backend/bruno/checkin/get_checkin.bru @@ -0,0 +1,26 @@ +meta { + name: Get Checkin By Id + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v1/checkins/{{checkinId}} + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: eq "{{checkinId}}" +} + +docs { + Detalhe de um check-in — paciente (próprio) ou profissional (mesma unidade). + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/checkin/get_history.bru b/backend/bruno/checkin/get_history.bru new file mode 100644 index 0000000..aa16537 --- /dev/null +++ b/backend/bruno/checkin/get_history.bru @@ -0,0 +1,27 @@ +meta { + name: Get Checkin History + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/checkins + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.items: isDefined + res.body.total: isDefined +} + +docs { + Histórico de check-ins do paciente autenticado. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/checkin/list_alerts.bru b/backend/bruno/checkin/list_alerts.bru new file mode 100644 index 0000000..28e8338 --- /dev/null +++ b/backend/bruno/checkin/list_alerts.bru @@ -0,0 +1,28 @@ +meta { + name: List Alerts + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/v1/alerts?patient_id={{patientId}} + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.items: isDefined + res.body.total: isDefined +} + +docs { + Lista alertas. Paciente: próprios alertas (sem query). + Profissional: exige patient_id; mesma unidade de saúde. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/checkin/resolve_alert.bru b/backend/bruno/checkin/resolve_alert.bru new file mode 100644 index 0000000..99361c6 --- /dev/null +++ b/backend/bruno/checkin/resolve_alert.bru @@ -0,0 +1,37 @@ +meta { + name: Resolve Alert + type: http + seq: 5 +} + +patch { + url: {{baseUrl}}/v1/alerts/{{alertId}}/resolve + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "notes": "Paciente orientado na consulta." + } +} + +assert { + res.status: eq 200 + res.body.resolved: eq true + res.body.id: eq "{{alertId}}" +} + +docs { + Profissional resolve um alerta (mesma unidade do paciente). + + Rate limit: 20/minuto. +} diff --git a/backend/bruno/checkin/submit_checkin.bru b/backend/bruno/checkin/submit_checkin.bru new file mode 100644 index 0000000..f1c5a41 --- /dev/null +++ b/backend/bruno/checkin/submit_checkin.bru @@ -0,0 +1,46 @@ +meta { + name: Submit Checkin + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/v1/checkins + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "mood": "ok", + "symptom_intensity": 5, + "symptom_ids": ["{{symptomId}}"], + "general_notes": "Dor leve nas mãos" + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.checked_in_at: isDefined + res.body.patient_id: isDefined + res.body.mood: eq "ok" +} + +docs { + Registra check-in diário do paciente (PEQ-100). + + - Um check-in por dia (409 se já existir). + - symptom_intensity: 0-10. + - symptom_ids: N:M com catálogo /v1/symptoms. + + Rate limit: 10/minuto. +} diff --git a/backend/src/pequi/core/rate_limit.py b/backend/src/pequi/core/rate_limit.py index d318f2f..dbaf5f3 100644 --- a/backend/src/pequi/core/rate_limit.py +++ b/backend/src/pequi/core/rate_limit.py @@ -1,12 +1,11 @@ +from fastapi import Request from slowapi import Limiter from slowapi.util import get_remote_address from pequi.config import get_settings +from pequi.core.auth import TOKEN_TYPE_ACCESS, JWTError, decode_token settings = get_settings() - -# Em produção usa Redis; em desenvolvimento/testes usa memória para não -# exigir Redis ativo durante smoke tests e desenvolvimento local sem infra. _storage_uri = settings.REDIS_URL if settings.is_production else "memory://" limiter = Limiter( @@ -15,4 +14,23 @@ default_limits=["1000/hour"], ) -__all__ = ["limiter"] + +def get_user_or_ip_key(request: Request) -> str: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + try: + payload = decode_token(auth[7:]) + if payload.get("type") == TOKEN_TYPE_ACCESS and payload.get("sub"): + return f"user:{payload['sub']}" + except JWTError: + pass + return get_remote_address(request) + + +user_limiter = Limiter( + key_func=get_user_or_ip_key, + storage_uri=_storage_uri, + default_limits=["1000/hour"], +) + +__all__ = ["limiter", "user_limiter", "get_user_or_ip_key"] diff --git a/backend/src/pequi/integrations/ai_client.py b/backend/src/pequi/integrations/ai_client.py new file mode 100644 index 0000000..9c7efab --- /dev/null +++ b/backend/src/pequi/integrations/ai_client.py @@ -0,0 +1,14 @@ +"""Cliente Anthropic — usado pelo worker de feedback de IA.""" + +from anthropic import AsyncAnthropic + +from pequi.config import get_settings + +settings = get_settings() + + +def get_anthropic_client() -> AsyncAnthropic | None: + api_key = settings.ANTHROPIC_API_KEY.strip() + if not api_key: + return None + return AsyncAnthropic(api_key=api_key) diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index fe50c1e..5148e40 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -67,6 +67,7 @@ async def health_check() -> JSONResponse: app.include_router(health_router) from pequi.routers import auth as auth_router + from pequi.routers import checkin as checkin_router from pequi.routers import patient as patient_router from pequi.routers import treatment as treatment_router @@ -74,9 +75,8 @@ async def health_check() -> JSONResponse: app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) app.include_router(treatment_router.router, prefix="/v1/treatments", tags=["treatments"]) app.include_router(treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"]) - - # M4: checkin.router → prefix="/v1/checkins" - # ... + app.include_router(checkin_router.router, prefix="/v1/checkins", tags=["checkins"]) + app.include_router(checkin_router.alerts_router, prefix="/v1/alerts", tags=["alerts"]) app = create_app() diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 363ee5d..020cb01 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -1,3 +1,5 @@ +from pequi.models.alert import Alert +from pequi.models.checkin import Checkin from pequi.models.consent import Consent from pequi.models.dose_log import AdherenceSnapshot, DoseLog from pequi.models.health_professional import HealthProfessional @@ -9,6 +11,8 @@ __all__ = [ "AdherenceSnapshot", + "Alert", + "Checkin", "Consent", "DoseLog", "DoseSchedule", diff --git a/backend/src/pequi/models/alert.py b/backend/src/pequi/models/alert.py new file mode 100644 index 0000000..dd0167f --- /dev/null +++ b/backend/src/pequi/models/alert.py @@ -0,0 +1,65 @@ +import uuid +from enum import StrEnum + +from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class AlertType(StrEnum): + symptom_spike = "symptom_spike" + missed_doses = "missed_doses" + mood_decline = "mood_decline" + new_lesion = "new_lesion" + + +class AlertSeverity(StrEnum): + low = "low" + medium = "medium" + high = "high" + critical = "critical" + + +class Alert(Base): + __tablename__ = "alerts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + checkin_id = Column( + UUID(as_uuid=True), + ForeignKey("checkins.id", ondelete="RESTRICT"), + nullable=True, + ) + type = Column( + Enum(AlertType, name="alert_type_enum"), + nullable=False, + ) + severity = Column( + Enum(AlertSeverity, name="alert_severity_enum"), + nullable=False, + ) + resolved = Column(Boolean, nullable=False, server_default="false") + resolved_at = Column(DateTime(timezone=True), nullable=True) + resolved_by = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=True, + ) + notes = Column(Text, nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/src/pequi/models/checkin.py b/backend/src/pequi/models/checkin.py new file mode 100644 index 0000000..c3c697f --- /dev/null +++ b/backend/src/pequi/models/checkin.py @@ -0,0 +1,78 @@ +import uuid +from enum import StrEnum + +from sqlalchemy import ( + Column, + DateTime, + Enum, + ForeignKey, + SmallInteger, + Table, + Text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from pequi.database import Base + + +class CheckinMood(StrEnum): + terrible = "terrible" + bad = "bad" + ok = "ok" + good = "good" + great = "great" + + +checkin_symptoms = Table( + "checkin_symptoms", + Base.metadata, + Column( + "checkin_id", + UUID(as_uuid=True), + ForeignKey("checkins.id", ondelete="RESTRICT"), + primary_key=True, + ), + Column( + "symptom_id", + UUID(as_uuid=True), + ForeignKey("symptoms.id", ondelete="RESTRICT"), + primary_key=True, + ), +) + + +class Checkin(Base): + __tablename__ = "checkins" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + mood = Column( + Enum(CheckinMood, name="checkin_mood_enum"), + nullable=False, + ) + symptom_intensity = Column(SmallInteger, nullable=False) + general_notes = Column(Text, nullable=True) + ai_feedback = Column(Text, nullable=True) + ai_feedback_at = Column(DateTime(timezone=True), nullable=True) + checked_in_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + symptoms = relationship( + "Symptom", + secondary=checkin_symptoms, + lazy="selectin", + ) diff --git a/backend/src/pequi/repositories/alert_repo.py b/backend/src/pequi/repositories/alert_repo.py new file mode 100644 index 0000000..45eb5de --- /dev/null +++ b/backend/src/pequi/repositories/alert_repo.py @@ -0,0 +1,64 @@ +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.alert import Alert, AlertType + + +class AlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, alert: Alert) -> Alert: + self._session.add(alert) + await self._session.flush() + await self._session.refresh(alert) + return alert + + async def get_by_id(self, alert_id: UUID) -> Alert | None: + stmt = select(Alert).where(Alert.id == alert_id) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient( + self, + patient_id: UUID, + *, + resolved: bool | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[Alert], int]: + filters = [Alert.patient_id == patient_id] + if resolved is not None: + filters.append(Alert.resolved == resolved) + + count_stmt = select(func.count()).select_from(Alert).where(*filters) + total = (await self._session.execute(count_stmt)).scalar_one() + + stmt = ( + select(Alert) + .where(*filters) + .order_by(Alert.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()), total + + async def has_unresolved(self, patient_id: UUID, alert_type: AlertType) -> bool: + stmt = select(Alert.id).where( + Alert.patient_id == patient_id, + Alert.type == alert_type, + Alert.resolved.is_(False), + ) + return (await self._session.execute(stmt)).scalar_one_or_none() is not None + + async def list_active(self, patient_id: UUID) -> list[Alert]: + items, _ = await self.list_by_patient(patient_id, resolved=False, limit=100) + return items + + async def save(self, alert: Alert) -> Alert: + await self._session.flush() + await self._session.refresh(alert) + return alert diff --git a/backend/src/pequi/repositories/checkin_repo.py b/backend/src/pequi/repositories/checkin_repo.py new file mode 100644 index 0000000..4c3392d --- /dev/null +++ b/backend/src/pequi/repositories/checkin_repo.py @@ -0,0 +1,109 @@ +from datetime import UTC, date, datetime +from uuid import UUID + +from sqlalchemy import func, insert, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from pequi.models.checkin import Checkin, CheckinMood, checkin_symptoms +from pequi.schemas.checkin import CheckinCreate + + +class CheckinRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def has_checkin_on_date(self, patient_id: UUID, day: date) -> bool: + stmt = select(Checkin.id).where( + Checkin.patient_id == patient_id, + func.date(func.timezone("UTC", Checkin.checked_in_at)) == day, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() is not None + + async def create( + self, + patient_id: UUID, + data: CheckinCreate, + *, + checked_in_at: datetime | None = None, + ) -> Checkin: + checkin = Checkin( + patient_id=patient_id, + mood=CheckinMood(data.mood), + symptom_intensity=data.symptom_intensity, + general_notes=data.general_notes, + checked_in_at=checked_in_at or datetime.now(UTC), + ) + self._session.add(checkin) + await self._session.flush() + + if data.symptom_ids: + await self._session.execute( + insert(checkin_symptoms), + [{"checkin_id": checkin.id, "symptom_id": sid} for sid in data.symptom_ids], + ) + + await self._session.flush() + return await self.get_by_id(checkin.id) # type: ignore[return-value] + + async def get_by_id(self, checkin_id: UUID) -> Checkin | None: + stmt = ( + select(Checkin).where(Checkin.id == checkin_id).options(selectinload(Checkin.symptoms)) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient( + self, + patient_id: UUID, + *, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[Checkin], int]: + count_stmt = ( + select(func.count()).select_from(Checkin).where(Checkin.patient_id == patient_id) + ) + total = (await self._session.execute(count_stmt)).scalar_one() + + stmt = ( + select(Checkin) + .where(Checkin.patient_id == patient_id) + .options(selectinload(Checkin.symptoms)) + .order_by(Checkin.checked_in_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()), total + + async def get_recent_moods( + self, + patient_id: UUID, + *, + limit: int = 3, + ) -> list[CheckinMood]: + stmt = ( + select(Checkin.mood) + .where(Checkin.patient_id == patient_id) + .order_by(Checkin.checked_in_at.desc()) + .limit(limit) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def update_ai_feedback( + self, + checkin_id: UUID, + feedback: str, + *, + feedback_at: datetime | None = None, + ) -> Checkin | None: + checkin = await self.get_by_id(checkin_id) + if checkin is None: + return None + checkin.ai_feedback = feedback + checkin.ai_feedback_at = feedback_at or datetime.now(UTC) + await self._session.flush() + await self._session.refresh(checkin) + return checkin diff --git a/backend/src/pequi/repositories/dose_repo.py b/backend/src/pequi/repositories/dose_repo.py index 4e35aa9..43ff975 100644 --- a/backend/src/pequi/repositories/dose_repo.py +++ b/backend/src/pequi/repositories/dose_repo.py @@ -1,7 +1,7 @@ -from datetime import datetime +from datetime import UTC, datetime, timedelta from uuid import UUID -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from pequi.core.logging import get_logger @@ -51,3 +51,25 @@ async def list_by_treatment(self, treatment_id: UUID) -> list[DoseLog]: ) result = await self._session.execute(stmt) return list(result.scalars().all()) + + async def count_missed_doses_in_week(self, patient_id: UUID) -> int: + """Doses esperadas na última semana sem taken_at e não puladas.""" + from pequi.models.treatment import Treatment + + week_ago = datetime.now(UTC) - timedelta(days=7) + now = datetime.now(UTC) + stmt = ( + select(func.count()) + .select_from(DoseLog) + .join(Treatment, DoseLog.treatment_id == Treatment.id) + .where( + Treatment.patient_id == patient_id, + Treatment.deleted_at.is_(None), + DoseLog.expected_at >= week_ago, + DoseLog.expected_at <= now, + DoseLog.taken_at.is_(None), + DoseLog.skipped.is_(False), + ) + ) + result = await self._session.execute(stmt) + return result.scalar_one() diff --git a/backend/src/pequi/repositories/treatment_repo.py b/backend/src/pequi/repositories/treatment_repo.py index ef81fa4..dda53cc 100644 --- a/backend/src/pequi/repositories/treatment_repo.py +++ b/backend/src/pequi/repositories/treatment_repo.py @@ -62,3 +62,10 @@ async def list_all(self) -> list[Symptom]: stmt = select(Symptom).order_by(Symptom.category, Symptom.name) result = await self._session.execute(stmt) return list(result.scalars().all()) + + async def get_by_ids(self, symptom_ids: list[UUID]) -> list[Symptom]: + if not symptom_ids: + return [] + stmt = select(Symptom).where(Symptom.id.in_(symptom_ids)) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/pequi/routers/checkin.py b/backend/src/pequi/routers/checkin.py new file mode 100644 index 0000000..0aa16eb --- /dev/null +++ b/backend/src/pequi/routers/checkin.py @@ -0,0 +1,148 @@ +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import ( + get_actor_from_token, + get_current_patient, + get_current_professional, + get_db, +) +from pequi.core.rate_limit import user_limiter +from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import SymptomRepository +from pequi.schemas.alert import AlertListResponse, AlertResolve, AlertResponse +from pequi.schemas.checkin import CheckinCreate, CheckinListResponse, CheckinResponse +from pequi.services.alert_service import AlertService +from pequi.use_cases.get_checkin import GetCheckinUseCase +from pequi.use_cases.get_checkin_history import GetCheckinHistoryUseCase +from pequi.use_cases.list_alerts import ListAlertsUseCase +from pequi.use_cases.resolve_alert import ResolveAlertUseCase +from pequi.use_cases.submit_checkin import SubmitCheckinUseCase +from pequi.workers.job_enqueue import ArqJobEnqueuer + +router = APIRouter() +alerts_router = APIRouter() + + +def _checkin_repos( + session: AsyncSession, +) -> tuple[ + CheckinRepository, + PatientRepository, + SymptomRepository, + AlertRepository, + DoseRepository, + HealthProfessionalRepository, +]: + return ( + CheckinRepository(session), + PatientRepository(session), + SymptomRepository(session), + AlertRepository(session), + DoseRepository(session), + HealthProfessionalRepository(session), + ) + + +@router.post("", response_model=CheckinResponse, status_code=201) +@user_limiter.limit("10/minute") +async def submit_checkin( + request: Request, + background_tasks: BackgroundTasks, + body: CheckinCreate, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> CheckinResponse: + checkin_repo, patient_repo, symptom_repo, alert_repo, dose_repo, _ = _checkin_repos(session) + alert_service = AlertService(alert_repo, checkin_repo, dose_repo) + use_case = SubmitCheckinUseCase(checkin_repo, patient_repo, symptom_repo, alert_service) + result = await use_case.execute(user_id, body) + + # Enqueue AI feedback after transaction commit (runs after response is sent) + if body.symptom_intensity >= 7: + enqueuer = ArqJobEnqueuer() + + async def enqueue_after_commit() -> None: + try: + await enqueuer.enqueue_ai_feedback(result.id) + except Exception: + from pequi.core.logging import get_logger + + logger = get_logger(__name__) + logger.exception("ai_feedback.enqueue_failed", checkin_id=str(result.id)) + + background_tasks.add_task(enqueue_after_commit) + + return result + + +@router.get("", response_model=CheckinListResponse) +@user_limiter.limit("100/minute") +async def get_checkin_history( + request: Request, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> CheckinListResponse: + checkin_repo, patient_repo, _, _, _, _ = _checkin_repos(session) + use_case = GetCheckinHistoryUseCase(checkin_repo, patient_repo) + return await use_case.execute(user_id, limit=limit, offset=offset) + + +@router.get("/{checkin_id}", response_model=CheckinResponse) +@user_limiter.limit("100/minute") +async def get_checkin( + request: Request, + checkin_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> CheckinResponse: + actor_user_id, actor_role = actor + checkin_repo, patient_repo, _, _, _, professional_repo = _checkin_repos(session) + use_case = GetCheckinUseCase(checkin_repo, patient_repo, professional_repo) + return await use_case.execute(actor_user_id, actor_role, checkin_id) + + +@alerts_router.get("", response_model=AlertListResponse) +@user_limiter.limit("100/minute") +async def list_alerts( + request: Request, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), + patient_id: UUID | None = Query(default=None), + resolved: bool | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> AlertListResponse: + actor_user_id, actor_role = actor + _, patient_repo, _, alert_repo, _, professional_repo = _checkin_repos(session) + use_case = ListAlertsUseCase(alert_repo, patient_repo, professional_repo) + return await use_case.execute( + actor_user_id, + actor_role, + patient_id=patient_id, + resolved=resolved, + limit=limit, + offset=offset, + ) + + +@alerts_router.patch("/{alert_id}/resolve", response_model=AlertResponse) +@user_limiter.limit("20/minute") +async def resolve_alert( + request: Request, + alert_id: UUID, + body: AlertResolve, + professional_user_id: UUID = Depends(get_current_professional), + session: AsyncSession = Depends(get_db), +) -> AlertResponse: + _, patient_repo, _, alert_repo, _, professional_repo = _checkin_repos(session) + use_case = ResolveAlertUseCase(alert_repo, patient_repo, professional_repo) + return await use_case.execute(professional_user_id, alert_id, body) diff --git a/backend/src/pequi/schemas/alert.py b/backend/src/pequi/schemas/alert.py new file mode 100644 index 0000000..1038c27 --- /dev/null +++ b/backend/src/pequi/schemas/alert.py @@ -0,0 +1,30 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class AlertResponse(BaseModel): + id: UUID + patient_id: UUID + checkin_id: UUID | None + type: str + severity: str + resolved: bool + resolved_at: datetime | None + notes: str | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class AlertListResponse(BaseModel): + items: list[AlertResponse] + total: int + + +class AlertResolve(BaseModel): + model_config = ConfigDict(extra="forbid") + + notes: str | None = Field(default=None, max_length=2000) diff --git a/backend/src/pequi/schemas/checkin.py b/backend/src/pequi/schemas/checkin.py new file mode 100644 index 0000000..4510f03 --- /dev/null +++ b/backend/src/pequi/schemas/checkin.py @@ -0,0 +1,74 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class CheckinCreate(BaseModel): + """Payload para registro de check-in diário (PEQ-100).""" + + model_config = ConfigDict(extra="forbid") + + mood: str = Field( + ..., + pattern="^(terrible|bad|ok|good|great)$", + description="Humor do paciente no dia", + ) + symptom_intensity: int = Field( + ..., + ge=0, + le=10, + description="Intensidade geral dos sintomas (0-10)", + ) + symptom_ids: list[UUID] = Field( + ..., + min_length=1, + description="Sintomas observados — referências ao catálogo", + ) + general_notes: str | None = Field(default=None, max_length=4000) + + +class SymptomBrief(BaseModel): + id: UUID + name: str + category: str + + model_config = ConfigDict(from_attributes=True) + + +class CheckinResponse(BaseModel): + id: UUID + patient_id: UUID + mood: str + symptom_intensity: int + symptom_ids: list[UUID] + symptoms: list[SymptomBrief] + general_notes: str | None + ai_feedback: str | None + ai_feedback_at: datetime | None + checked_in_at: datetime + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CheckinListResponse(BaseModel): + items: list[CheckinResponse] + total: int + + +def checkin_to_response(checkin) -> CheckinResponse: + symptoms = checkin.symptoms or [] + return CheckinResponse( + id=checkin.id, + patient_id=checkin.patient_id, + mood=checkin.mood.value, + symptom_intensity=checkin.symptom_intensity, + symptom_ids=[s.id for s in symptoms], + symptoms=[SymptomBrief.model_validate(s) for s in symptoms], + general_notes=checkin.general_notes, + ai_feedback=checkin.ai_feedback, + ai_feedback_at=checkin.ai_feedback_at, + checked_in_at=checkin.checked_in_at, + created_at=checkin.created_at, + ) diff --git a/backend/src/pequi/services/ai_feedback_service.py b/backend/src/pequi/services/ai_feedback_service.py new file mode 100644 index 0000000..dc674f9 --- /dev/null +++ b/backend/src/pequi/services/ai_feedback_service.py @@ -0,0 +1,63 @@ +"""Gera feedback de IA para check-ins críticos — sem PII na saída.""" + +import re + +from pequi.config import get_settings +from pequi.core.logging import get_logger +from pequi.integrations.ai_client import get_anthropic_client +from pequi.models.checkin import Checkin + +logger = get_logger(__name__) +settings = get_settings() + +# Padrões que não devem aparecer no feedback retornado ao paciente +_PII_PATTERNS = [ + re.compile(r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b"), # CPF + re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"), # email +] + +_FALLBACK_FEEDBACK = ( + "Registramos que seus sintomas estão em nível elevado hoje. " + "Continue o tratamento conforme orientado e entre em contato com sua unidade " + "de saúde se sentir piora. Estamos acompanhando você." +) + + +class AIFeedbackService: + async def generate_feedback(self, checkin: Checkin) -> str: + """Gera texto de apoio clínico sem dados pessoais identificáveis.""" + client = get_anthropic_client() + if client is None: + logger.info("ai_feedback.skipped", reason="no_api_key") + return _FALLBACK_FEEDBACK + + symptom_names = [s.name for s in checkin.symptoms] if checkin.symptoms else [] + prompt = ( + "Você é um assistente de saúde para pacientes com hanseníase. " + "Gere um parágrafo curto (máx. 3 frases) de orientação empática em português. " + "NÃO inclua nome, CPF, e-mail, endereço ou qualquer dado pessoal. " + "Use apenas: humor, intensidade de sintomas (0-10) e nomes genéricos de sintomas.\n\n" + f"Humor: {checkin.mood.value}\n" + f"Intensidade: {checkin.symptom_intensity}/10\n" + f"Sintomas relatados: {', '.join(symptom_names) or 'nenhum específico'}\n" + ) + + try: + response = await client.messages.create( + model=settings.ANTHROPIC_MODEL, + max_tokens=256, + messages=[{"role": "user", "content": prompt}], + ) + if not response.content: + return _FALLBACK_FEEDBACK + text = response.content[0].text.strip() # type: ignore[union-attr] + except Exception: + logger.warning("ai_feedback.api_error", checkin_id=str(checkin.id)) + return _FALLBACK_FEEDBACK + + return self._sanitize(text) + + def _sanitize(self, text: str) -> str: + for pattern in _PII_PATTERNS: + text = pattern.sub("[redacted]", text) + return text diff --git a/backend/src/pequi/services/alert_service.py b/backend/src/pequi/services/alert_service.py new file mode 100644 index 0000000..30b903a --- /dev/null +++ b/backend/src/pequi/services/alert_service.py @@ -0,0 +1,96 @@ +import uuid + +from pequi.core.logging import get_logger +from pequi.models.alert import Alert, AlertSeverity, AlertType +from pequi.models.checkin import Checkin, CheckinMood +from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.dose_repo import DoseRepository + +logger = get_logger(__name__) + +_MISSED_DOSES_THRESHOLD = 3 +_MOOD_DECLINE_CONSECUTIVE_DAYS = 3 + + +class AlertService: + def __init__( + self, + alert_repo: AlertRepository, + checkin_repo: CheckinRepository, + dose_repo: DoseRepository, + ) -> None: + self._alert_repo = alert_repo + self._checkin_repo = checkin_repo + self._dose_repo = dose_repo + + async def evaluate_after_checkin(self, checkin: Checkin) -> list[Alert]: + """Avalia regras de alerta após um check-in e persiste os gerados.""" + created: list[Alert] = [] + + spike = self._evaluate_symptom_spike(checkin) + if spike is not None and not await self._alert_repo.has_unresolved( + checkin.patient_id, AlertType.symptom_spike + ): + created.append(await self._alert_repo.create(spike)) + + if await self._should_create_mood_decline( + checkin.patient_id + ) and not await self._alert_repo.has_unresolved(checkin.patient_id, AlertType.mood_decline): + mood_alert = Alert( + id=uuid.uuid4(), + patient_id=checkin.patient_id, + checkin_id=checkin.id, + type=AlertType.mood_decline, + severity=AlertSeverity.medium, + ) + created.append(await self._alert_repo.create(mood_alert)) + + missed_count = await self._dose_repo.count_missed_doses_in_week(checkin.patient_id) + if missed_count > _MISSED_DOSES_THRESHOLD and not await self._alert_repo.has_unresolved( + checkin.patient_id, AlertType.missed_doses + ): + dose_alert = Alert( + id=uuid.uuid4(), + patient_id=checkin.patient_id, + checkin_id=checkin.id, + type=AlertType.missed_doses, + severity=AlertSeverity.high, + ) + created.append(await self._alert_repo.create(dose_alert)) + + for alert in created: + logger.warning( + "alert.generated", + type=alert.type.value, + severity=alert.severity.value, + patient_id=str(checkin.patient_id), + ) + + return created + + def _evaluate_symptom_spike(self, checkin: Checkin) -> Alert | None: + intensity = checkin.symptom_intensity + if intensity >= 8: + severity = AlertSeverity.critical + elif intensity >= 6: + severity = AlertSeverity.high + else: + return None + + return Alert( + id=uuid.uuid4(), + patient_id=checkin.patient_id, + checkin_id=checkin.id, + type=AlertType.symptom_spike, + severity=severity, + ) + + async def _should_create_mood_decline(self, patient_id: uuid.UUID) -> bool: + moods = await self._checkin_repo.get_recent_moods( + patient_id, + limit=_MOOD_DECLINE_CONSECUTIVE_DAYS, + ) + if len(moods) < _MOOD_DECLINE_CONSECUTIVE_DAYS: + return False + return all(m == CheckinMood.terrible for m in moods) diff --git a/backend/src/pequi/services/notification_service.py b/backend/src/pequi/services/notification_service.py new file mode 100644 index 0000000..f6fe3b8 --- /dev/null +++ b/backend/src/pequi/services/notification_service.py @@ -0,0 +1,17 @@ +"""Notificações ao paciente — stub para integração WhatsApp (M10).""" + +from uuid import UUID + +from pequi.core.logging import get_logger + +logger = get_logger(__name__) + + +class NotificationService: + async def send_feedback(self, patient_id: UUID, feedback: str) -> None: + """Envia feedback de IA ao paciente (push/WhatsApp quando disponível).""" + logger.info( + "notification.feedback_queued", + patient_id=str(patient_id), + feedback_length=len(feedback), + ) diff --git a/backend/src/pequi/use_cases/get_checkin.py b/backend/src/pequi/use_cases/get_checkin.py new file mode 100644 index 0000000..afd9807 --- /dev/null +++ b/backend/src/pequi/use_cases/get_checkin.py @@ -0,0 +1,64 @@ +from uuid import UUID + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.core.logging import get_logger +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.checkin import CheckinResponse, checkin_to_response + +logger = get_logger(__name__) + + +class GetCheckinUseCase: + def __init__( + self, + checkin_repo: CheckinRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._checkin_repo = checkin_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: UUID, + actor_role: str, + checkin_id: UUID, + ) -> CheckinResponse: + checkin = await self._checkin_repo.get_by_id(checkin_id) + if checkin is None: + raise NotFoundError("Checkin", str(checkin_id)) + + if actor_role == "patient": + patient = await self._patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != checkin.patient_id: + raise ForbiddenError("Paciente não tem acesso a este check-in.") + elif actor_role == "health_professional": + await self._validate_professional_access(actor_user_id, checkin.patient_id) + logger.info( + "audit.checkin.accessed_by_professional", + professional_user_id=str(actor_user_id), + patient_id=str(checkin.patient_id), + checkin_id=str(checkin_id), + ) + else: + raise ForbiddenError("Acesso negado.") + + return checkin_to_response(checkin) + + async def _validate_professional_access( + self, + actor_user_id: UUID, + patient_id: UUID, + ) -> None: + professional = await self._professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await self._patient_repo.get_by_id(patient_id) + if patient is None or patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a dados de pacientes de outra unidade." + ) diff --git a/backend/src/pequi/use_cases/get_checkin_history.py b/backend/src/pequi/use_cases/get_checkin_history.py new file mode 100644 index 0000000..7821976 --- /dev/null +++ b/backend/src/pequi/use_cases/get_checkin_history.py @@ -0,0 +1,35 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.checkin import CheckinListResponse, checkin_to_response + + +class GetCheckinHistoryUseCase: + def __init__( + self, + checkin_repo: CheckinRepository, + patient_repo: PatientRepository, + ) -> None: + self._checkin_repo = checkin_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + *, + limit: int = 50, + offset: int = 0, + ) -> CheckinListResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + items, total = await self._checkin_repo.list_by_patient( + patient.id, limit=limit, offset=offset + ) + return CheckinListResponse( + items=[checkin_to_response(c) for c in items], + total=total, + ) diff --git a/backend/src/pequi/use_cases/list_alerts.py b/backend/src/pequi/use_cases/list_alerts.py new file mode 100644 index 0000000..fc6d532 --- /dev/null +++ b/backend/src/pequi/use_cases/list_alerts.py @@ -0,0 +1,76 @@ +from uuid import UUID + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.core.logging import get_logger +from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.alert import AlertListResponse, AlertResponse + +logger = get_logger(__name__) + + +class ListAlertsUseCase: + def __init__( + self, + alert_repo: AlertRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._alert_repo = alert_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: UUID, + actor_role: str, + *, + patient_id: UUID | None = None, + resolved: bool | None = None, + limit: int = 50, + offset: int = 0, + ) -> AlertListResponse: + target_patient_id = await self._resolve_patient_id(actor_user_id, actor_role, patient_id) + items, total = await self._alert_repo.list_by_patient( + target_patient_id, + resolved=resolved, + limit=limit, + offset=offset, + ) + return AlertListResponse( + items=[AlertResponse.model_validate(a) for a in items], + total=total, + ) + + async def _resolve_patient_id( + self, + actor_user_id: UUID, + actor_role: str, + patient_id: UUID | None, + ) -> UUID: + if actor_role == "patient": + patient = await self._patient_repo.get_by_user_id(actor_user_id) + if patient is None: + raise NotFoundError("PatientProfile") + return patient.id + + if actor_role == "health_professional": + if patient_id is None: + raise ForbiddenError("Profissional deve informar patient_id para listar alertas.") + professional = await self._professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + patient = await self._patient_repo.get_by_id(patient_id) + if patient is None or patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a alertas de pacientes de outra unidade." + ) + logger.info( + "audit.alerts.accessed_by_professional", + professional_user_id=str(actor_user_id), + patient_id=str(patient_id), + ) + return patient.id + + raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/resolve_alert.py b/backend/src/pequi/use_cases/resolve_alert.py new file mode 100644 index 0000000..7719822 --- /dev/null +++ b/backend/src/pequi/use_cases/resolve_alert.py @@ -0,0 +1,52 @@ +from datetime import UTC, datetime +from uuid import UUID + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.alert import AlertResolve, AlertResponse + + +class ResolveAlertUseCase: + def __init__( + self, + alert_repo: AlertRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._alert_repo = alert_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + professional_user_id: UUID, + alert_id: UUID, + data: AlertResolve, + ) -> AlertResponse: + alert = await self._alert_repo.get_by_id(alert_id) + if alert is None: + raise NotFoundError("Alert", str(alert_id)) + + professional = await self._professional_repo.get_by_user_id(professional_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await self._patient_repo.get_by_id(alert.patient_id) + if patient is None or patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a alertas de pacientes de outra unidade." + ) + + if alert.resolved: + return AlertResponse.model_validate(alert) + + alert.resolved = True + alert.resolved_at = datetime.now(UTC) + alert.resolved_by = professional_user_id + if data.notes is not None: + alert.notes = data.notes + + alert = await self._alert_repo.save(alert) + return AlertResponse.model_validate(alert) diff --git a/backend/src/pequi/use_cases/submit_checkin.py b/backend/src/pequi/use_cases/submit_checkin.py new file mode 100644 index 0000000..23adeb0 --- /dev/null +++ b/backend/src/pequi/use_cases/submit_checkin.py @@ -0,0 +1,61 @@ +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy.exc import IntegrityError + +from pequi.core.exceptions import ConflictError, NotFoundError, ValidationFailedError +from pequi.core.logging import get_logger +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import SymptomRepository +from pequi.schemas.checkin import CheckinCreate, CheckinResponse, checkin_to_response +from pequi.services.alert_service import AlertService + +logger = get_logger(__name__) +_AI_FEEDBACK_INTENSITY_THRESHOLD = 7 +_DUPLICATE_CHECKIN_MSG = "Já existe um check-in registrado para hoje." + + +class SubmitCheckinUseCase: + def __init__( + self, + checkin_repo: CheckinRepository, + patient_repo: PatientRepository, + symptom_repo: SymptomRepository, + alert_service: AlertService, + ) -> None: + self._checkin_repo = checkin_repo + self._patient_repo = patient_repo + self._symptom_repo = symptom_repo + self._alert_service = alert_service + + async def execute(self, user_id: UUID, data: CheckinCreate) -> CheckinResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + if len(data.symptom_ids) != len(set(data.symptom_ids)): + raise ValidationFailedError("symptom_ids não pode conter duplicatas.") + + today = datetime.now(UTC).date() + if await self._checkin_repo.has_checkin_on_date(patient.id, today): + raise ConflictError(_DUPLICATE_CHECKIN_MSG) + + catalog = await self._symptom_repo.get_by_ids(data.symptom_ids) + if len(catalog) != len(set(data.symptom_ids)): + raise ValidationFailedError( + "Um ou mais symptom_ids são inválidos ou não existem no catálogo." + ) + + try: + checkin = await self._checkin_repo.create(patient.id, data) + except IntegrityError as exc: + cname = getattr(getattr(exc, "orig", None), "constraint_name", None) or "" + if cname == "uq_checkins_patient_one_per_day": + raise ConflictError(_DUPLICATE_CHECKIN_MSG) from exc + if "checkin_symptoms" in cname: + raise ValidationFailedError("symptom_ids não pode conter duplicatas.") from exc + raise + + await self._alert_service.evaluate_after_checkin(checkin) + return checkin_to_response(checkin) diff --git a/backend/src/pequi/workers/ai_feedback_worker.py b/backend/src/pequi/workers/ai_feedback_worker.py new file mode 100644 index 0000000..dae1c19 --- /dev/null +++ b/backend/src/pequi/workers/ai_feedback_worker.py @@ -0,0 +1,25 @@ +"""Worker ARQ: gera feedback de IA e notifica o paciente.""" + +from uuid import UUID + +from pequi.database import AsyncSessionLocal +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.services.ai_feedback_service import AIFeedbackService +from pequi.services.notification_service import NotificationService + + +async def ai_feedback_job(ctx: dict, checkin_id: str) -> None: + checkin_uuid = UUID(checkin_id) + ai_service = AIFeedbackService() + notification_service = NotificationService() + + async with AsyncSessionLocal() as session: + checkin_repo = CheckinRepository(session) + checkin = await checkin_repo.get_by_id(checkin_uuid) + if checkin is None: + return + + feedback = await ai_service.generate_feedback(checkin) + await checkin_repo.update_ai_feedback(checkin_uuid, feedback) + await notification_service.send_feedback(checkin.patient_id, feedback) + await session.commit() diff --git a/backend/src/pequi/workers/job_enqueue.py b/backend/src/pequi/workers/job_enqueue.py new file mode 100644 index 0000000..b7362ab --- /dev/null +++ b/backend/src/pequi/workers/job_enqueue.py @@ -0,0 +1,45 @@ +"""Enfileiramento de jobs ARQ — desacoplado para testes.""" + +from uuid import UUID + +from arq import create_pool +from arq.connections import ArqRedis, RedisSettings + +from pequi.config import get_settings +from pequi.core.logging import get_logger +from pequi.workers.settings import WorkerSettings + +logger = get_logger(__name__) +_arq_pool: ArqRedis | None = None + + +async def _get_pool() -> ArqRedis: + global _arq_pool + if _arq_pool is None: + settings = get_settings() + _arq_pool = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) + return _arq_pool + + +class JobEnqueuer: + async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: + raise NotImplementedError + + +class ArqJobEnqueuer(JobEnqueuer): + async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: + pool = await _get_pool() + await pool.enqueue_job( + "ai_feedback_job", + str(checkin_id), + _queue_name=WorkerSettings.queue_name, + ) + logger.info("ai_feedback.enqueued", checkin_id=str(checkin_id)) + + +class NoOpJobEnqueuer(JobEnqueuer): + def __init__(self) -> None: + self.enqueued: list[UUID] = [] + + async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: + self.enqueued.append(checkin_id) diff --git a/backend/src/pequi/workers/settings.py b/backend/src/pequi/workers/settings.py new file mode 100644 index 0000000..34cc85b --- /dev/null +++ b/backend/src/pequi/workers/settings.py @@ -0,0 +1,16 @@ +from arq.connections import RedisSettings + +from pequi.config import get_settings +from pequi.workers.ai_feedback_worker import ai_feedback_job + +settings = get_settings() + + +class WorkerSettings: + """Configuração do worker ARQ.""" + + queue_name = "pequi:default" + redis_settings = RedisSettings.from_dsn(settings.REDIS_URL) + functions = [ai_feedback_job] + max_jobs = 10 + job_timeout = 120 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d1292cd..f239296 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,14 +9,16 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool +import pequi.models # noqa: F401 — registra todas as tabelas no metadata antes do create_all from pequi.config import get_settings from pequi.core.dependencies import get_db -from pequi.core.rate_limit import limiter +from pequi.core.rate_limit import limiter, user_limiter from pequi.database import Base from pequi.main import app # Disable rate limiting for tests limiter.enabled = False +user_limiter.enabled = False settings = get_settings() diff --git a/backend/tests/integration/test_alert_after_checkin.py b/backend/tests/integration/test_alert_after_checkin.py new file mode 100644 index 0000000..6ae1395 --- /dev/null +++ b/backend/tests/integration/test_alert_after_checkin.py @@ -0,0 +1,82 @@ +"""Integração PEQ-101 — alertas automáticos após check-in.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest + +from pequi.models.alert import AlertSeverity, AlertType +from pequi.models.dose_log import DoseLog +from pequi.models.symptom import Symptom, SymptomCategory +from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.dose_repo import DoseRepository +from pequi.schemas.checkin import CheckinCreate +from pequi.services.alert_service import AlertService +from tests.integration.test_dose_flow import ( + _create_health_unit, + _create_patient, + _create_professional, + _create_treatment, + _create_user, +) + + +async def _symptom(session) -> Symptom: + s = Symptom(id=uuid4(), name="Dor", category=SymptomCategory.systemic) + session.add(s) + await session.flush() + return s + + +@pytest.mark.asyncio +async def test_mood_decline_after_three_terrible_checkins(create_tables, db_session): + hu = await _create_health_unit(db_session) + user = await _create_user(db_session, email="al1@test.com", role="patient") + patient = await _create_patient(db_session, user=user, health_unit=hu) + symptom = await _symptom(db_session) + repo = CheckinRepository(db_session) + svc = AlertService(AlertRepository(db_session), repo, DoseRepository(db_session)) + data = CheckinCreate(mood="terrible", symptom_intensity=2, symptom_ids=[symptom.id]) + now = datetime.now(UTC) + for days_ago in (2, 1): + await repo.create(patient.id, data, checked_in_at=now - timedelta(days=days_ago)) + checkin = await repo.create(patient.id, data, checked_in_at=now) + alerts = await svc.evaluate_after_checkin(checkin) + assert any( + a.type == AlertType.mood_decline and a.severity == AlertSeverity.medium for a in alerts + ) + + +@pytest.mark.asyncio +async def test_missed_doses_alert_when_four_missed_in_week(create_tables, db_session): + hu = await _create_health_unit(db_session) + pu = await _create_user(db_session, email="al2@test.com", role="patient") + prof = await _create_user(db_session, email="pr2@test.com", role="health_professional") + patient = await _create_patient(db_session, user=pu, health_unit=hu) + professional = await _create_professional(db_session, user=prof, health_unit=hu) + treatment = await _create_treatment(db_session, patient=patient, professional=professional) + symptom = await _symptom(db_session) + now = datetime.now(UTC) + for i in range(4): + db_session.add( + DoseLog( + id=uuid4(), + treatment_id=treatment.id, + drug_name=f"Drug{i}", + expected_at=now - timedelta(days=i + 1), + skipped=False, + ) + ) + await db_session.flush() + repo = CheckinRepository(db_session) + checkin = await repo.create( + patient.id, + CheckinCreate(mood="ok", symptom_intensity=3, symptom_ids=[symptom.id]), + ) + alerts = await AlertService( + AlertRepository(db_session), repo, DoseRepository(db_session) + ).evaluate_after_checkin(checkin) + assert any( + a.type == AlertType.missed_doses and a.severity == AlertSeverity.high for a in alerts + ) diff --git a/backend/tests/integration/test_checkin_flow.py b/backend/tests/integration/test_checkin_flow.py new file mode 100644 index 0000000..5714e90 --- /dev/null +++ b/backend/tests/integration/test_checkin_flow.py @@ -0,0 +1,173 @@ +"""Testes de integração — fluxo de check-in diário (M4 / PEQ-100).""" + +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ConflictError, ForbiddenError, ValidationFailedError +from pequi.models.alert import AlertSeverity, AlertType +from pequi.models.symptom import Symptom, SymptomCategory +from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import SymptomRepository +from pequi.schemas.checkin import CheckinCreate +from pequi.services.alert_service import AlertService +from pequi.use_cases.list_alerts import ListAlertsUseCase +from pequi.use_cases.submit_checkin import SubmitCheckinUseCase +from tests.integration.test_dose_flow import ( + _create_health_unit, + _create_patient, + _create_professional, + _create_user, +) + + +async def _create_symptom(session, *, name: str = "Dormência") -> Symptom: + symptom = Symptom( + id=uuid4(), + name=name, + category=SymptomCategory.neurological, + description="Test symptom", + ) + session.add(symptom) + await session.flush() + return symptom + + +def _make_submit_use_case(session) -> SubmitCheckinUseCase: + checkin_repo = CheckinRepository(session) + alert_service = AlertService( + AlertRepository(session), + checkin_repo, + DoseRepository(session), + ) + return SubmitCheckinUseCase( + checkin_repo, + PatientRepository(session), + SymptomRepository(session), + alert_service, + ) + + +@pytest.mark.asyncio +async def test_patient_submits_daily_checkin(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="chk1@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + symptom = await _create_symptom(db_session) + + data = CheckinCreate( + mood="ok", + symptom_intensity=5, + symptom_ids=[symptom.id], + general_notes="Dor leve nas mãos", + ) + + use_case = _make_submit_use_case(db_session) + result = await use_case.execute(patient_user.id, data) + + assert result.patient_id == patient.id + assert result.mood == "ok" + assert result.symptom_intensity == 5 + assert symptom.id in result.symptom_ids + assert result.ai_feedback is None + + +@pytest.mark.asyncio +async def test_duplicate_checkin_same_day_returns_conflict(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="chk2@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + symptom = await _create_symptom(db_session) + + data = CheckinCreate(mood="good", symptom_intensity=3, symptom_ids=[symptom.id]) + use_case = _make_submit_use_case(db_session) + await use_case.execute(patient_user.id, data) + + with pytest.raises(ConflictError): + await use_case.execute(patient_user.id, data) + + +@pytest.mark.asyncio +async def test_symptom_spike_critical_when_intensity_ge_8(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="chk3@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + symptom = await _create_symptom(db_session) + + data = CheckinCreate(mood="bad", symptom_intensity=9, symptom_ids=[symptom.id]) + use_case = _make_submit_use_case(db_session) + await use_case.execute(patient_user.id, data) + + alert_repo = AlertRepository(db_session) + alerts, _ = await alert_repo.list_by_patient(patient.id) + spike = next(a for a in alerts if a.type == AlertType.symptom_spike) + assert spike.severity == AlertSeverity.critical + + +@pytest.mark.asyncio +async def test_ai_feedback_job_enqueued_when_intensity_ge_7(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="chk4@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + symptom = await _create_symptom(db_session) + + data = CheckinCreate(mood="terrible", symptom_intensity=7, symptom_ids=[symptom.id]) + use_case = _make_submit_use_case(db_session) + result = await use_case.execute(patient_user.id, data) + + # AI feedback enqueue is now handled in router via BackgroundTasks + # This test verifies the use case returns the checkin correctly + assert result.ai_feedback is None + + +@pytest.mark.asyncio +async def test_duplicate_symptom_ids_rejected(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="chk6b@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + symptom = await _create_symptom(db_session) + data = CheckinCreate(mood="ok", symptom_intensity=3, symptom_ids=[symptom.id, symptom.id]) + with pytest.raises(ValidationFailedError): + await _make_submit_use_case(db_session).execute(patient_user.id, data) + + +@pytest.mark.asyncio +async def test_enqueue_failure_does_not_block_checkin(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="chk6@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + symptom = await _create_symptom(db_session) + + data = CheckinCreate(mood="bad", symptom_intensity=8, symptom_ids=[symptom.id]) + result = await _make_submit_use_case(db_session).execute(patient_user.id, data) + assert result.symptom_intensity == 8 + + +@pytest.mark.asyncio +async def test_professional_from_another_unit_cannot_list_alerts(create_tables, db_session): + unit_a = await _create_health_unit(db_session, name="UBS Norte") + unit_b = await _create_health_unit(db_session, name="UBS Sul") + + patient_user = await _create_user(db_session, email="chk5@test.com", role="patient") + prof_b_user = await _create_user( + db_session, email="prof_b_chk@test.com", role="health_professional" + ) + patient = await _create_patient(db_session, user=patient_user, health_unit=unit_a) + await _create_professional(db_session, user=prof_b_user, health_unit=unit_b) + + use_case = ListAlertsUseCase( + AlertRepository(db_session), + PatientRepository(db_session), + HealthProfessionalRepository(db_session), + ) + + with pytest.raises(ForbiddenError): + await use_case.execute( + prof_b_user.id, + "health_professional", + patient_id=patient.id, + ) diff --git a/backend/tests/integration/test_checkin_history.py b/backend/tests/integration/test_checkin_history.py new file mode 100644 index 0000000..968015a --- /dev/null +++ b/backend/tests/integration/test_checkin_history.py @@ -0,0 +1,101 @@ +"""PEQ-102 — histórico e detalhe de check-ins.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.models.symptom import Symptom, SymptomCategory +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.checkin import CheckinCreate +from pequi.use_cases.get_checkin import GetCheckinUseCase +from pequi.use_cases.get_checkin_history import GetCheckinHistoryUseCase +from tests.integration.test_dose_flow import ( + _create_health_unit, + _create_patient, + _create_user, +) + + +async def _symptom(session) -> Symptom: + s = Symptom(id=uuid4(), name="Fraqueza", category=SymptomCategory.systemic) + session.add(s) + await session.flush() + return s + + +@pytest.mark.asyncio +async def test_history_lists_patient_checkins_newest_first(create_tables, db_session): + hu = await _create_health_unit(db_session) + user = await _create_user(db_session, email="h1@test.com", role="patient") + patient = await _create_patient(db_session, user=user, health_unit=hu) + symptom = await _symptom(db_session) + repo = CheckinRepository(db_session) + data = CheckinCreate(mood="ok", symptom_intensity=4, symptom_ids=[symptom.id]) + now = datetime.now(UTC) + older = await repo.create(patient.id, data, checked_in_at=now - timedelta(days=2)) + newer = await repo.create(patient.id, data, checked_in_at=now - timedelta(days=1)) + + result = await GetCheckinHistoryUseCase(repo, PatientRepository(db_session)).execute(user.id) + + assert result.total == 2 + assert result.items[0].id == newer.id + assert result.items[1].id == older.id + + +@pytest.mark.asyncio +async def test_get_checkin_detail_for_patient(create_tables, db_session): + hu = await _create_health_unit(db_session) + user = await _create_user(db_session, email="h2@test.com", role="patient") + patient = await _create_patient(db_session, user=user, health_unit=hu) + symptom = await _symptom(db_session) + checkin = await CheckinRepository(db_session).create( + patient.id, + CheckinCreate(mood="good", symptom_intensity=2, symptom_ids=[symptom.id]), + ) + + detail = await GetCheckinUseCase( + CheckinRepository(db_session), + PatientRepository(db_session), + HealthProfessionalRepository(db_session), + ).execute(user.id, "patient", checkin.id) + + assert detail.id == checkin.id + assert detail.mood == "good" + + +@pytest.mark.asyncio +async def test_patient_cannot_view_other_patient_checkin(create_tables, db_session): + hu = await _create_health_unit(db_session) + u1 = await _create_user(db_session, email="h3a@test.com", role="patient") + u2 = await _create_user(db_session, email="h3b@test.com", role="patient") + p1 = await _create_patient(db_session, user=u1, health_unit=hu) + await _create_patient(db_session, user=u2, health_unit=hu) + symptom = await _symptom(db_session) + checkin = await CheckinRepository(db_session).create( + p1.id, CheckinCreate(mood="ok", symptom_intensity=1, symptom_ids=[symptom.id]) + ) + + with pytest.raises(ForbiddenError): + await GetCheckinUseCase( + CheckinRepository(db_session), + PatientRepository(db_session), + HealthProfessionalRepository(db_session), + ).execute(u2.id, "patient", checkin.id) + + +@pytest.mark.asyncio +async def test_get_checkin_not_found(create_tables, db_session): + user = await _create_user(db_session, email="h4@test.com", role="patient") + hu = await _create_health_unit(db_session) + await _create_patient(db_session, user=user, health_unit=hu) + + with pytest.raises(NotFoundError): + await GetCheckinUseCase( + CheckinRepository(db_session), + PatientRepository(db_session), + HealthProfessionalRepository(db_session), + ).execute(user.id, "patient", uuid4()) diff --git a/backend/tests/unit/test_ai_feedback_service.py b/backend/tests/unit/test_ai_feedback_service.py new file mode 100644 index 0000000..ccdaa8b --- /dev/null +++ b/backend/tests/unit/test_ai_feedback_service.py @@ -0,0 +1,10 @@ +from pequi.services.ai_feedback_service import AIFeedbackService + + +def test_sanitize_removes_cpf_and_email(): + service = AIFeedbackService() + text = "Contato: joao@email.com ou CPF 123.456.789-00" + result = service._sanitize(text) + assert "joao@email.com" not in result + assert "123.456.789-00" not in result + assert "[redacted]" in result diff --git a/backend/tests/unit/test_alert_service.py b/backend/tests/unit/test_alert_service.py new file mode 100644 index 0000000..780a116 --- /dev/null +++ b/backend/tests/unit/test_alert_service.py @@ -0,0 +1,130 @@ +"""Testes unitários do AlertService — regras de negócio M4.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from pequi.models.alert import AlertSeverity, AlertType +from pequi.models.checkin import Checkin, CheckinMood +from pequi.services.alert_service import AlertService + + +def _make_checkin(*, intensity: int = 5, mood: CheckinMood = CheckinMood.ok) -> Checkin: + checkin = MagicMock(spec=Checkin) + checkin.id = uuid4() + checkin.patient_id = uuid4() + checkin.symptom_intensity = intensity + checkin.mood = mood + return checkin + + +@pytest.mark.asyncio +async def test_symptom_spike_critical_when_intensity_ge_8(): + alert_repo = AsyncMock() + alert_repo.create.side_effect = lambda a: a + alert_repo.has_unresolved = AsyncMock(return_value=False) + checkin_repo = AsyncMock() + checkin_repo.get_recent_moods.return_value = [] + dose_repo = AsyncMock() + dose_repo.count_missed_doses_in_week.return_value = 0 + + service = AlertService(alert_repo, checkin_repo, dose_repo) + checkin = _make_checkin(intensity=8) + + alerts = await service.evaluate_after_checkin(checkin) + + spike = next(a for a in alerts if a.type == AlertType.symptom_spike) + assert spike.severity == AlertSeverity.critical + + +@pytest.mark.asyncio +async def test_symptom_spike_high_when_intensity_ge_6(): + alert_repo = AsyncMock() + alert_repo.create.side_effect = lambda a: a + alert_repo.has_unresolved = AsyncMock(return_value=False) + checkin_repo = AsyncMock() + checkin_repo.get_recent_moods.return_value = [] + dose_repo = AsyncMock() + dose_repo.count_missed_doses_in_week.return_value = 0 + + service = AlertService(alert_repo, checkin_repo, dose_repo) + checkin = _make_checkin(intensity=7) + + alerts = await service.evaluate_after_checkin(checkin) + + spike = next(a for a in alerts if a.type == AlertType.symptom_spike) + assert spike.severity == AlertSeverity.high + + +@pytest.mark.asyncio +async def test_no_spike_when_intensity_below_6(): + alert_repo = AsyncMock() + alert_repo.create.side_effect = lambda a: a + alert_repo.has_unresolved = AsyncMock(return_value=False) + checkin_repo = AsyncMock() + checkin_repo.get_recent_moods.return_value = [] + dose_repo = AsyncMock() + dose_repo.count_missed_doses_in_week.return_value = 0 + + service = AlertService(alert_repo, checkin_repo, dose_repo) + checkin = _make_checkin(intensity=5) + + alerts = await service.evaluate_after_checkin(checkin) + + assert not any(a.type == AlertType.symptom_spike for a in alerts) + + +@pytest.mark.asyncio +async def test_mood_decline_after_three_terrible_days(): + alert_repo = AsyncMock() + alert_repo.create.side_effect = lambda a: a + alert_repo.has_unresolved = AsyncMock(return_value=False) + checkin_repo = AsyncMock() + checkin_repo.get_recent_moods.return_value = [ + CheckinMood.terrible, + CheckinMood.terrible, + CheckinMood.terrible, + ] + dose_repo = AsyncMock() + dose_repo.count_missed_doses_in_week.return_value = 0 + + service = AlertService(alert_repo, checkin_repo, dose_repo) + checkin = _make_checkin(intensity=3, mood=CheckinMood.terrible) + + alerts = await service.evaluate_after_checkin(checkin) + + assert any(a.type == AlertType.mood_decline for a in alerts) + + +@pytest.mark.asyncio +async def test_mood_decline_not_created_with_only_two_terrible_days(): + alert_repo = AsyncMock() + alert_repo.create.side_effect = lambda a: a + alert_repo.has_unresolved.return_value = False + checkin_repo = AsyncMock() + checkin_repo.get_recent_moods.return_value = [CheckinMood.terrible, CheckinMood.terrible] + dose_repo = AsyncMock() + dose_repo.count_missed_doses_in_week.return_value = 0 + + service = AlertService(alert_repo, checkin_repo, dose_repo) + alerts = await service.evaluate_after_checkin(_make_checkin(mood=CheckinMood.terrible)) + + assert not any(a.type == AlertType.mood_decline for a in alerts) + + +@pytest.mark.asyncio +async def test_missed_doses_when_more_than_three_in_week(): + alert_repo = AsyncMock() + alert_repo.create.side_effect = lambda a: a + alert_repo.has_unresolved.return_value = False + checkin_repo = AsyncMock() + checkin_repo.get_recent_moods.return_value = [] + dose_repo = AsyncMock() + dose_repo.count_missed_doses_in_week.return_value = 4 + + service = AlertService(alert_repo, checkin_repo, dose_repo) + alerts = await service.evaluate_after_checkin(_make_checkin(intensity=2)) + + dose = next(a for a in alerts if a.type == AlertType.missed_doses) + assert dose.severity == AlertSeverity.high diff --git a/backend/tests/unit/test_checkin_schema.py b/backend/tests/unit/test_checkin_schema.py new file mode 100644 index 0000000..d2406cd --- /dev/null +++ b/backend/tests/unit/test_checkin_schema.py @@ -0,0 +1,17 @@ +import pytest +from pydantic import ValidationError + +from pequi.schemas.checkin import CheckinCreate + + +def test_symptom_intensity_must_be_0_to_10(): + with pytest.raises(ValidationError): + CheckinCreate(mood="ok", symptom_intensity=11, symptom_ids=[]) + + with pytest.raises(ValidationError): + CheckinCreate(mood="ok", symptom_intensity=-1, symptom_ids=[]) + + +def test_symptom_ids_requires_at_least_one(): + with pytest.raises(ValidationError): + CheckinCreate(mood="ok", symptom_intensity=5, symptom_ids=[]) diff --git a/sonar-project.properties b/sonar-project.properties index 80d07a2..1a7a237 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,14 +1,14 @@ sonar.projectKey=PequiProject_pequi sonar.organization=pequiproject - # This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=pequi -#sonar.projectVersion=1.0 - +sonar.projectName=pequi +sonar.projectVersion=1.0 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -#sonar.sources=. +sonar.sources=backend/src +sonar.tests=backend/tests +sonar.python.coverage.reportPaths=backend/coverage.xml # Encoding of the source code. Default is default system encoding -#sonar.sourceEncoding=UTF-8 \ No newline at end of file +sonar.sourceEncoding=UTF-8 \ No newline at end of file From e847389b63545ad9dd91ce08b3b4f0b99182f429 Mon Sep 17 00:00:00 2001 From: sarahdomingos Date: Mon, 25 May 2026 10:55:43 -0300 Subject: [PATCH 14/69] =?UTF-8?q?fix:=20corre=C3=A7=C3=B5es=20do=20coment?= =?UTF-8?q?=C3=A1rio=20do=20PR=20aplicadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkin-step-details-component.html | 4 +- .../checkin-step-details-component.spec.ts | 2 +- .../src/app/features/checkin/checkin.spec.ts | 279 +++++++++++------- frontend/src/app/features/checkin/checkin.ts | 83 +++++- 4 files changed, 243 insertions(+), 125 deletions(-) diff --git a/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.html b/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.html index 071d0e7..66155e1 100644 --- a/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.html +++ b/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.html @@ -16,7 +16,7 @@ class="mt-2 max-w-2xl text-base leading-8 text-[#6B6B67]" data-testid="details-description" > - Descreva onde você está notando mudanças hoje. Cada detalhe ajuda. + Descreva onde você está notando mudanças ou detalhe os seus principais sintomas de hoje. Cada detalhe ajuda.

@@ -36,7 +36,7 @@ id="notes" formControlName="notes" rows="6" - placeholder="Ex.: senti mais desconforto no período da manhã, após caminhar, ou percebi melhora ao longo do dia." + placeholder="Ex.: senti mais desconforto no período da manhã, após caminhar. A pele seca está piorando, mas a fraqueza está menor que ontem." data-testid="details-textarea" class="min-h-36 w-full rounded-xl border border-[#D9D4CE] px-4 py-3 text-sm text-[#373831] outline-none transition placeholder:text-[#9A968F] focus:border-[#436D57]" > diff --git a/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.spec.ts b/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.spec.ts index 68d8f7b..400b07c 100644 --- a/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.spec.ts +++ b/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.spec.ts @@ -44,7 +44,7 @@ describe(CheckinStepDetailsComponent.name, () => { const descriptionText = getByTestId('details-description').nativeElement.textContent; expect(titleText).toContain('Quer adicionar mais detalhes?'); - expect(descriptionText).toContain('Descreva onde você está notando mudanças hoje. Cada detalhe ajuda.'); + expect(descriptionText).toContain('Descreva onde você está notando mudanças ou detalhe os seus principais sintomas de hoje. Cada detalhe ajuda.'); }); it('should render textarea block, label and textarea', () => { diff --git a/frontend/src/app/features/checkin/checkin.spec.ts b/frontend/src/app/features/checkin/checkin.spec.ts index d957871..085bdeb 100644 --- a/frontend/src/app/features/checkin/checkin.spec.ts +++ b/frontend/src/app/features/checkin/checkin.spec.ts @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { By } from '@angular/platform-browser'; -import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { vi } from 'vitest'; @@ -62,39 +62,39 @@ describe(CheckinComponent.name, () => { const getButtons = () => fixture.debugElement.queryAll(By.css('button')).map(btn => btn.nativeElement as HTMLButtonElement); -beforeEach(async () => { - router = { - navigate: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [CheckinComponent], - providers: [{ provide: Router, useValue: router }], - }) - .overrideComponent(CheckinComponent, { - remove: { - imports: [ - CheckinStepFeelingComponent, - CheckinStepSymptomsComponent, - CheckinStepIntensityComponent, - CheckinStepDetailsComponent, - ], - }, - add: { - imports: [ - CheckinStepFeelingStubComponent, - CheckinStepSymptomsStubComponent, - CheckinStepIntensityStubComponent, - CheckinStepDetailsStubComponent, - ], - }, - }) - .compileComponents(); + beforeEach(async () => { + router = { + navigate: vi.fn(), + }; - fixture = TestBed.createComponent(CheckinComponent); - component = fixture.componentInstance; - fixture.detectChanges(); -}); + await TestBed.configureTestingModule({ + imports: [CheckinComponent], + providers: [{ provide: Router, useValue: router }], + }) + .overrideComponent(CheckinComponent, { + remove: { + imports: [ + CheckinStepFeelingComponent, + CheckinStepSymptomsComponent, + CheckinStepIntensityComponent, + CheckinStepDetailsComponent, + ], + }, + add: { + imports: [ + CheckinStepFeelingStubComponent, + CheckinStepSymptomsStubComponent, + CheckinStepIntensityStubComponent, + CheckinStepDetailsStubComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CheckinComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); it('should create', () => { expect(component).toBeTruthy(); @@ -125,13 +125,13 @@ beforeEach(async () => { it('should disable previous button on step 1', () => { const [prevButton] = getButtons(); - expect(prevButton.disabled).toBeTruthy(); + expect(prevButton.disabled).toBe(true); }); it('should disable next button when current step is invalid', () => { const [, nextButton] = getButtons(); - expect(component.isCurrentStepInvalid()).toBeTruthy(); - expect(nextButton.disabled).toBeTruthy(); + expect(component.isCurrentStepInvalid()).toBe(true); + expect(nextButton.disabled).toBe(true); }); it('should expose subforms correctly', () => { @@ -142,16 +142,16 @@ beforeEach(async () => { }); it('should identify active and completed steps correctly', () => { - expect(component.isStepActive(1)).toBeTruthy(); - expect(component.isStepCompleted(1)).toBeFalsy(); - expect(component.isStepCompleted(2)).toBeFalsy(); + expect(component.isStepActive(1)).toBe(true); + expect(component.isStepCompleted(1)).toBe(false); + expect(component.isStepCompleted(2)).toBe(false); component.currentStep.set(3); - expect(component.isStepActive(3)).toBeTruthy(); - expect(component.isStepCompleted(1)).toBeTruthy(); - expect(component.isStepCompleted(2)).toBeTruthy(); - expect(component.isStepCompleted(3)).toBeFalsy(); + expect(component.isStepActive(3)).toBe(true); + expect(component.isStepCompleted(1)).toBe(true); + expect(component.isStepCompleted(2)).toBe(true); + expect(component.isStepCompleted(3)).toBe(false); }); it('should allow going back to a previous step', () => { @@ -174,7 +174,7 @@ beforeEach(async () => { component.nextStep(); expect(component.currentStep()).toBe(1); - expect(component.feelingForm.touched).toBeTruthy(); + expect(component.feelingForm.touched).toBe(true); }); it('should advance from step 1 to step 2 when feeling form is valid', () => { @@ -203,19 +203,27 @@ beforeEach(async () => { component.nextStep(); expect(component.currentStep()).toBe(2); - expect(component.symptomsForm.touched).toBeTruthy(); + expect(component.symptomsForm.touched).toBe(true); }); - it('should advance from step 2 to step 3 when symptoms form is valid', () => { + it('should advance from step 2 to step 3 when symptoms form has regular symptoms', () => { component.currentStep.set(2); component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); - fixture.detectChanges(); component.nextStep(); expect(component.currentStep()).toBe(3); }); + it('should skip from step 2 to step 4 when "nenhum sintoma" is selected', () => { + component.currentStep.set(2); + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + + component.nextStep(); + + expect(component.currentStep()).toBe(4); + }); + it('should render intensity step on step 3', () => { component.currentStep.set(3); fixture.detectChanges(); @@ -226,50 +234,91 @@ beforeEach(async () => { expect(getByTestId('details-step')).toBeNull(); }); - it('should not advance from step 3 when intensity form is invalid', () => { + it('should render details step on step 4', () => { + component.currentStep.set(4); + fixture.detectChanges(); + + expect(getByTestId('feeling-step')).toBeNull(); + expect(getByTestId('symptoms-step')).toBeNull(); + expect(getByTestId('intensity-step')).toBeNull(); + expect(getByTestId('details-step')).toBeTruthy(); + }); + + it('should keep intensity required when there are symptoms', () => { + component.symptomsForm.get('selectedSymptoms')?.setValue(['headache']); + + const scaleControl = component.intensityForm.get('scale'); + + expect(scaleControl?.hasValidator(Validators.required)).toBe(true); + expect(component.intensityForm.invalid).toBe(true); + }); + + it('should remove required validator from intensity when "nenhum sintoma" is selected', () => { + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + + const scaleControl = component.intensityForm.get('scale'); + + expect(scaleControl?.hasValidator(Validators.required)).toBe(false); + expect(component.intensityForm.valid).toBe(true); + }); + + it('should clear intensity value when "nenhum sintoma" is selected', () => { + component.intensityForm.get('scale')?.setValue(6); + + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + + expect(component.intensityForm.get('scale')?.value).toBeNull(); + }); + + it('should restore required validator to intensity when symptoms change from "nenhum sintoma" to regular symptom', () => { + const scaleControl = component.intensityForm.get('scale'); + + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + expect(scaleControl?.hasValidator(Validators.required)).toBe(false); + + component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + + expect(scaleControl?.hasValidator(Validators.required)).toBe(true); + expect(component.intensityForm.invalid).toBe(true); + }); + + it('should not advance from step 3 when intensity is required and invalid', () => { + component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); component.currentStep.set(3); fixture.detectChanges(); component.nextStep(); expect(component.currentStep()).toBe(3); - expect(component.intensityForm.touched).toBeTruthy(); + expect(component.intensityForm.touched).toBe(true); }); - it('should advance from step 3 to step 4 when intensity form is valid', () => { + it('should advance from step 3 to step 4 when intensity is valid', () => { + component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); component.currentStep.set(3); component.intensityForm.get('scale')?.setValue(4); - fixture.detectChanges(); component.nextStep(); expect(component.currentStep()).toBe(4); }); - it('should render details step on step 4', () => { + it('should go back from step 4 to step 3 in regular flow', () => { + component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); component.currentStep.set(4); - fixture.detectChanges(); - expect(getByTestId('feeling-step')).toBeNull(); - expect(getByTestId('symptoms-step')).toBeNull(); - expect(getByTestId('intensity-step')).toBeNull(); - expect(getByTestId('details-step')).toBeTruthy(); - }); - - it('should allow advancing to step 4 even with empty details because details is optional', () => { - component.currentStep.set(4); - fixture.detectChanges(); + component.prevStep(); - expect(component.detailsForm.valid).toBeTruthy(); - expect(component.isCurrentStepInvalid()).toBeFalsy(); + expect(component.currentStep()).toBe(3); }); - it('should go back from step 4 to step 3', () => { + it('should go back from step 4 to step 2 when "nenhum sintoma" was selected', () => { + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); component.currentStep.set(4); component.prevStep(); - expect(component.currentStep()).toBe(3); + expect(component.currentStep()).toBe(2); }); it('should go back from step 3 to step 2', () => { @@ -328,55 +377,23 @@ beforeEach(async () => { it('should keep next button disabled on invalid required steps', () => { component.currentStep.set(1); - fixture.detectChanges(); - let [, nextButton] = getButtons(); - expect(nextButton.disabled).toBeTruthy(); + expect(component.feelingForm.invalid).toBe(true); component.currentStep.set(2); - fixture.detectChanges(); - [, nextButton] = getButtons(); - expect(nextButton.disabled).toBeTruthy(); + expect(component.symptomsForm.invalid).toBe(true); + component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); component.currentStep.set(3); - fixture.detectChanges(); - [, nextButton] = getButtons(); - expect(nextButton.disabled).toBeTruthy(); + expect(component.intensityForm.invalid).toBe(true); }); -it('should enable next button when step 1 becomes valid', () => { - component.currentStep.set(1); - component.feelingForm.get('mood')?.setValue('ok'); - fixture.detectChanges(); - - const [, nextButton] = getButtons(); - expect(nextButton.disabled).toBeFalsy(); -}); - -it('should enable next button when step 2 becomes valid', () => { - component.currentStep.set(2); - component.symptomsForm.get('selectedSymptoms')?.setValue(['headache']); - fixture.detectChanges(); - - const [, nextButton] = getButtons(); - expect(nextButton.disabled).toBeFalsy(); -}); - -it('should enable next button when step 3 becomes valid', () => { - component.currentStep.set(3); - component.intensityForm.get('scale')?.setValue(2); - fixture.detectChanges(); - - const [, nextButton] = getButtons(); - expect(nextButton.disabled).toBeFalsy(); -}); - it('should enable submit button on step 4 because details is optional', () => { component.currentStep.set(4); fixture.detectChanges(); const [, submitButton] = getButtons(); - expect(component.isCurrentStepInvalid()).toBeFalsy(); - expect(submitButton.disabled).toBeFalsy(); + expect(component.isCurrentStepInvalid()).toBe(false); + expect(submitButton.disabled).toBe(false); }); it('should not submit when the full form is invalid', () => { @@ -388,27 +405,38 @@ it('should enable next button when step 3 becomes valid', () => { it('should mark full form as touched when submit is called with invalid form', () => { component.submit(); - expect(component.form.touched).toBeTruthy(); + expect(component.form.touched).toBe(true); }); - it('should submit and navigate to home when form is valid', () => { - vi.spyOn(console, 'log'); - + it('should submit and navigate to home when form is valid in regular flow', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); component.feelingForm.get('mood')?.setValue('happy'); component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); - component.symptomsForm.get('customSymptom')?.setValue('optional ignored'); component.intensityForm.get('scale')?.setValue(1); component.detailsForm.get('notes')?.setValue('feeling well'); component.submit(); - expect(console.log).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['home']); + }); + + it('should submit and navigate to home when "nenhum sintoma" skips intensity', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + component.feelingForm.get('mood')?.setValue('happy'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + component.detailsForm.get('notes')?.setValue('sem sintomas hoje'); + + component.submit(); + + expect(consoleSpy).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['home']); }); it('should submit payload with only selectedSymptoms inside symptoms object', () => { - const consoleSpy = vi.spyOn(console, 'log'); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); component.feelingForm.get('mood')?.setValue('sad'); component.symptomsForm.get('selectedSymptoms')?.setValue(['nausea']); @@ -426,7 +454,24 @@ it('should enable next button when step 3 becomes valid', () => { }); }); - it('should follow the new flow without skipping from step 2 to step 4', () => { + it('should submit payload with null intensity when "nenhum sintoma" is selected', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + component.detailsForm.get('notes')?.setValue('sem observações'); + + component.submit(); + + expect(consoleSpy).toHaveBeenCalledWith('Payload final do check-in:', { + feeling: { mood: 'good' }, + symptoms: { selectedSymptoms: ['nenhum sintoma'] }, + intensity: { scale: null }, + details: { notes: 'sem observações' }, + }); + }); + + it('should follow the regular flow without skipping when there are symptoms', () => { component.feelingForm.get('mood')?.setValue('good'); component.nextStep(); expect(component.currentStep()).toBe(2); @@ -439,4 +484,14 @@ it('should enable next button when step 3 becomes valid', () => { component.nextStep(); expect(component.currentStep()).toBe(4); }); + + it('should follow the skip flow when "nenhum sintoma" is selected', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.nextStep(); + expect(component.currentStep()).toBe(2); + + component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); + component.nextStep(); + expect(component.currentStep()).toBe(4); + }); }); \ No newline at end of file diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index 2469ce0..3902e7e 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -14,6 +14,7 @@ import { Validators, } from '@angular/forms'; import { Router } from '@angular/router'; +import { Subscription } from 'rxjs'; import { CheckinStepFeelingComponent } from '../../components/checkin-step-feeling-component/checkin-step-feeling-component'; import { CheckinStepSymptomsComponent } from '../../components/checkin-step-symptoms-component/checkin-step-symptoms-component'; import { CheckinStepIntensityComponent } from '../../components/checkin-step-intensity-component/checkin-step-intensity-component'; @@ -42,6 +43,11 @@ export class CheckinComponent { private readonly fb = inject(FormBuilder); private readonly router = inject(Router); + private stepStatusSubscription?: Subscription; + private symptomsSelectionSubscription?: Subscription; + + private readonly NO_SYMPTOM_VALUE = 'nenhum sintoma'; + steps: StepItem[] = [ { id: 1, label: 'Ranking de Sentimentos' }, { id: 2, label: 'Seleção de Sintomas' }, @@ -74,16 +80,8 @@ export class CheckinComponent { }); constructor() { - effect(() => { - const step = this.currentStep(); - const currentGroup = this.getStepForm(step); - - this.isCurrentStepInvalid.set(currentGroup.invalid); - - currentGroup.statusChanges.subscribe(() => { - this.isCurrentStepInvalid.set(currentGroup.invalid); - }); - }); + this.setupCurrentStepValidationWatcher(); + this.setupIntensityConditionalValidation(); } get currentStepNumber(): WritableSignal { @@ -133,12 +131,20 @@ export class CheckinComponent { case 1: this.currentStep.set(2); return; + case 2: + if (this.hasNoSymptomsSelected()) { + this.currentStep.set(4); + return; + } + this.currentStep.set(3); return; + case 3: this.currentStep.set(4); return; + default: return; } @@ -147,14 +153,22 @@ export class CheckinComponent { prevStep(): void { switch (this.currentStep()) { case 4: + if (this.hasNoSymptomsSelected()) { + this.currentStep.set(2); + return; + } + this.currentStep.set(3); return; + case 3: this.currentStep.set(2); return; + case 2: this.currentStep.set(1); return; + default: return; } @@ -196,4 +210,53 @@ export class CheckinComponent { return this.feelingForm; } } + + private hasNoSymptomsSelected(): boolean { + const selectedSymptoms = + this.symptomsForm.get('selectedSymptoms')?.value ?? []; + + return selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); + } + + private setupCurrentStepValidationWatcher(): void { + effect(() => { + const step = this.currentStep(); + const currentGroup = this.getStepForm(step); + + this.stepStatusSubscription?.unsubscribe(); + this.isCurrentStepInvalid.set(currentGroup.invalid); + + this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { + this.isCurrentStepInvalid.set(currentGroup.invalid); + }); + }); + } + + private setupIntensityConditionalValidation(): void { + const selectedSymptomsControl = this.symptomsForm.get('selectedSymptoms'); + const intensityScaleControl = this.intensityForm.get('scale'); + + this.applyIntensityValidation(); + + this.symptomsSelectionSubscription = selectedSymptomsControl?.valueChanges.subscribe(() => { + this.applyIntensityValidation(); + }); + } + + private applyIntensityValidation(): void { + const intensityScaleControl = this.intensityForm.get('scale'); + + if (!intensityScaleControl) { + return; + } + + if (this.hasNoSymptomsSelected()) { + intensityScaleControl.clearValidators(); + intensityScaleControl.setValue(null, { emitEvent: false }); + } else { + intensityScaleControl.setValidators([Validators.required]); + } + + intensityScaleControl.updateValueAndValidity({ emitEvent: true }); + } } \ No newline at end of file From ea767e790cf53442ad0974a993223b461ee14403 Mon Sep 17 00:00:00 2001 From: sarahdomingos Date: Mon, 25 May 2026 16:26:47 -0300 Subject: [PATCH 15/69] hotfix: sentimentos ordenados do melhor para pior em checkin --- .../checkin-step-feeling-component.ts | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts index 522d101..4d156f0 100644 --- a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts +++ b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts @@ -24,18 +24,18 @@ export class CheckinStepFeelingComponent { moodOptions: MoodOption[] = [ { - value: 'terrivel', - label: 'Terrível', - emoji: '😔', - color: 'bg-[#D95C5C]', - bars: 1, + value: 'otimo', + label: 'Ótimo', + emoji: '😄', + color: 'bg-[#6B5CCF]', + bars: 5, }, { - value: 'mal', - label: 'Mal', - emoji: '🙁', - color: 'bg-[#D98A3A]', - bars: 2, + value: 'muito-bem', + label: 'Muito Bem', + emoji: '🙂', + color: 'bg-[#5C9B7B]', + bars: 4, }, { value: 'bem', @@ -45,18 +45,18 @@ export class CheckinStepFeelingComponent { bars: 3, }, { - value: 'muito-bem', - label: 'Muito Bem', - emoji: '🙂', - color: 'bg-[#5C9B7B]', - bars: 4, + value: 'mal', + label: 'Mal', + emoji: '🙁', + color: 'bg-[#D98A3A]', + bars: 2, }, { - value: 'otimo', - label: 'Ótimo', - emoji: '😄', - color: 'bg-[#6B5CCF]', - bars: 5, + value: 'terrivel', + label: 'Terrível', + emoji: '😔', + color: 'bg-[#D95C5C]', + bars: 1, }, ]; From 82f98171d3bb5decf3481c6f7d3f326c0c6f2e0e Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Tue, 26 May 2026 14:14:51 -0300 Subject: [PATCH 16/69] chore: trigger preview deploy From 7dc3f066f42c64cb0d04ba2d40a03c6ef0e80646 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Tue, 26 May 2026 14:18:03 -0300 Subject: [PATCH 17/69] chore: trigger preview another deploy From 15ba227f195725b63b8333903d9933c7a4ba272b Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Tue, 26 May 2026 16:39:54 -0300 Subject: [PATCH 18/69] =?UTF-8?q?PEQ-129:=20Atualiza=20README=20com=20arqu?= =?UTF-8?q?itetura=20e=20isntru=C3=A7=C3=B5es=20de=20setup=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9552e6a..7fa2f46 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,223 @@ -# pequi -Software de gestão e acompanhamento de pacientes com hanseníase. +
+ + + + + + +
+ animated + +

Pequi

+

Plataforma de acompanhamento de pacientes com hanseníase —
do check-in diário ao painel do profissional de saúde.

+

Desenvolvido por alunos da Universidade Federal de Alagoas (UFAL),
o Pequi conecta pacientes e profissionais de saúde em um único fluxo do registro diário de sintomas ao acompanhamento clínico especializado.

+ Licença MIT + Versão + CI status +
+ +--- + +## Sobre o projeto + +O Brasil registra historicamente um dos maiores números de casos novos de hanseníase do mundo. Apesar de ter cura, a doença exige tratamento prolongado — de seis meses a dois anos — e o abandono do tratamento é a principal causa de recidivas e de complicações que levam à incapacidade física permanente. + +Pequi nasceu para reduzir esse abandono. O aplicativo permite que pacientes registrem sintomas e doses diárias de forma simples, enquanto profissionais de saúde acompanham a adesão, recebem alertas automáticos e se comunicam com suas equipes. A plataforma também oferece um espaço de comunidade anônima, onde pacientes podem compartilhar experiências sem expor sua identidade. + +O projeto é desenvolvido como software de código aberto para unidades de saúde pública e organizações que atuam no combate à hanseníase no Brasil. + +--- + +## Para quem é este repositório + +| Perfil | O que encontra aqui | +|---|---| +| Desenvolvedor novo | Instruções para subir o ambiente local do zero | +| Contribuidor | Como abrir um PR e onde está cada parte do código | +| Gestor de saúde / parceiro | Visão geral do produto e links para documentação detalhada | + +--- + +## Arquitetura em alto nível + +```mermaid +flowchart TD + U["👤 Usuário\n(paciente ou profissional de saúde)"] + F["frontend/ · Angular 21\nInterface web — check-in, painel, comunidade"] + B["backend/ · FastAPI + Python 3.12\nAPI REST, autenticação JWT, regras de negócio"] + PG[("PostgreSQL\ndados clínicos")] + R[("Redis\ncache / filas")] + M[("MinIO / R2\nimagens de lesões")] + W["Workers ARQ\naderência · notificações · resumos assíncronos"] + + U -->|HTTPS| F + F -->|"REST /v1/"| B + B --> PG + B --> R + B --> M + PG -.-> W + R -.-> W +``` + +--- + +## Pré-requisitos + +Antes de clonar o projeto, certifique-se de ter instalado: + +| Ferramenta | Versão mínima | Uso | +|---|---|---| +| [Docker](https://docs.docker.com/get-docker/) | 24.x | Subir toda a stack (banco, cache, storage, API) | +| [Docker Compose](https://docs.docker.com/compose/) | v2.x | Orquestrar os serviços | +| [Node.js](https://nodejs.org/) | 20.x LTS | Desenvolvimento do frontend | +| [Python](https://www.python.org/) | 3.12+ | Desenvolvimento do backend | +| [UV](https://docs.astral.sh/uv/) | última versão | Gerenciador de pacotes Python | + +> Para contribuições apenas no frontend, Docker + Node são suficientes. +> Para contribuições apenas no backend, Docker + Python + UV cobrem o essencial. + +--- + +## Início rápido + +### 1. Clone o repositório + +```bash +git clone https://github.com/seu-org/pequi.git +cd pequi +``` + +### 2. Configure as variáveis de ambiente do backend + +```bash +cp backend/.env.example backend/.env +# Edite backend/.env se necessário — os valores padrão já funcionam para desenvolvimento local +``` + +### 3. Suba a stack completa + +```bash +cd backend +docker compose up -d +``` + +Isso inicia: +- `db` — PostgreSQL com PostGIS na porta `5432` +- `redis` — Redis na porta `6379` +- `minio` — armazenamento de objetos nas portas `9000` (API) e `9001` (console web) +- `migrate` — aplica as migrações Alembic automaticamente na primeira subida +- `api` — API FastAPI em `http://localhost:8000` (com hot reload) +- `worker` — processador de filas ARQ + +Verifique se tudo subiu: + +```bash +docker compose ps +``` + +A documentação interativa da API estará disponível em `http://localhost:8000/docs`. + +### 4. Suba o frontend + +Em outro terminal, a partir da raiz do repositório: + +```bash +cd frontend +npm install +npm start +``` + +O app estará acessível em `http://localhost:4200`. + +--- + +## Estrutura do repositório + +``` +pequi/ +├── backend/ # API FastAPI, workers ARQ, migrações Alembic +│ ├── src/pequi/ # Código-fonte principal da aplicação +│ ├── tests/ # Testes unitários, de integração e E2E +│ ├── alembic/ # Migrações de banco de dados +│ ├── bruno/ # Coleções Bruno (contratos de endpoints) +│ └── scripts/ # Scripts de CI e utilitários de desenvolvimento +│ +├── frontend/ # App Angular 21 (interface do paciente e do profissional) +│ └── src/ # Componentes, páginas e serviços Angular +│ +├── docs/ # Documentação de produto: roadmap, épicos, milestones +│ +├── .agents/ # Guias e workflows para agentes de IA e desenvolvedores +│ +├── .github/ +│ └── workflows/ # Pipelines de CI/CD (ci, build, lint, tests, release, security) +│ +├── AGENTS.md # Guia técnico principal do backend (convenções, camadas, LGPD) +├── CHANGELOG.md # Histórico de versões (Keep a Changelog + SemVer) +└── LICENSE # MIT +``` + +--- + +## Documentação detalhada + +| Documento | O que cobre | +|---|---| +| [`backend/README.md`](backend/README.md) | Setup do backend, execução local, arquitetura em camadas, API, banco de dados, testes e observabilidade | +| [`AGENTS.md`](AGENTS.md) | Convenções de desenvolvimento, cadeia de dependências, padrões de código, LGPD, rate limiting e boas práticas para contribuidores e agentes de IA | +| [`frontend/README.md`](frontend/README.md) | Servidor de desenvolvimento Angular, scaffolding, build e execução de testes com Vitest | +| [`docs/ROADMAP.md`](docs/ROADMAP.md) | Visão de produto e entregas planejadas | +| [`CHANGELOG.md`](CHANGELOG.md) | Histórico de mudanças por versão | + +--- + +## Como contribuir + +1. Faça um fork do repositório e clone localmente. + +2. Sincronize com a branch `development` antes de criar a sua: + +```bash +git checkout development +git pull origin development +git checkout -b feature/minha-contribuicao +``` + +3. Implemente a mudança seguindo as convenções descritas em [`AGENTS.md`](AGENTS.md). + +4. Rode lint e testes antes de abrir o PR: + +```bash +# Backend +cd backend +uv run ruff check . +uv run ruff format --check . +scripts/run_tests.sh + +# Frontend +cd frontend +npm test +``` + +5. Abra um Pull Request contra a branch `development` (não contra `main`). O pipeline [`ci.yml`](.github/workflows/ci.yml) roda automaticamente — o PR só pode ser mergeado com todos os checks passando. + +6. Descreva claramente no PR o quê e por quê foi alterado. PRs focados (uma feature ou um fix) são revisados mais rapidamente. + +> Dúvidas sobre o fluxo de branches? Consulte [`.agents/rules/gitflow.md`](.agents/rules/gitflow.md). + +--- + +## Licença + +Distribuído sob a licença MIT. Consulte o arquivo [`LICENSE`](LICENSE) para detalhes. + +--- + +## Contato e mantenedores + +* [Leila Biggi](https://github.com/lawtherea) +* [Lucas Heron](https://github.com/LukeHer0) +* [Matheus Ryan](https://github.com/TETEURYAN) +* [Rafael Luciano](https://github.com/rafaellucian0) +* [Sarah Domingos](https://github.com/sarahdomingos) + From 436a1d93edb1a8b4d74e06594f587a3bb8044616 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Tue, 26 May 2026 18:45:04 -0300 Subject: [PATCH 19/69] PEQ-80: Build Body Map Evaluate (#24) * feat: add body map functionality with models, repositories, and API endpoints - Introduced BodyArea, BodyMapEntry, and BodyAreaHistory models to represent body mapping data. - Implemented BodyMapRepository for database interactions related to body map entries and areas. - Created API endpoints for managing body maps, including retrieval, updates, and upload URL generation. - Added use cases for handling body map history and updates, ensuring proper validation and error handling. - Integrated body map functionality into existing check-in processes to enhance patient data tracking. Co-authored-by: Rafael Luciano * feat: create body map tables and related functionality - Added migration script to create body map tables: body_areas, body_map_entries, and body_area_history. - Introduced ENUM types for body sides, system parts, and finding types to standardize data entries. - Implemented bulk insert for initial body area data to enhance patient mapping capabilities. - Established foreign key constraints and indexes to optimize data integrity and query performance. This update lays the groundwork for enhanced body mapping features in the application. * tests(body map): add integration and unit tests for body map functionality - Introduced integration tests for body map API endpoints, covering body area creation, updates, and retrieval. - Implemented unit tests for BodyMapUpdateRequest schema validation, ensuring proper handling of intensity and finding type constraints. - Enhanced test coverage for body mapping features to ensure robustness and reliability in the application. These additions improve the overall test suite and validate the functionality of the body map features. Co-authored-by: Rafael Luciano * feat: add body map API endpoints for retrieval and updates - Introduced new endpoints for getting the current body map, its history, and listing body areas. - Implemented functionality for updating the body map with entries and handling invalid area updates. - Added image upload capabilities with validation for file types. These additions enhance the body mapping features, providing comprehensive access and management of patient body map data. Co-authored-by: Rafael Luciano * refactor: remove unused UTC import from test_body_map.py - Cleaned up the import statements in the integration test file by removing the unused UTC import, streamlining the code for better readability and maintainability. * refactor: remove unused import from test_body_map.py - Eliminated the unused import of `datetime` from the integration test file, improving code clarity and maintainability. * chore: add datetime import to test_body_map.py - Included the `datetime` import in the integration test file to facilitate date handling in future test cases. This addition prepares the file for upcoming enhancements related to date functionalities. Co-authored-by: Rafael Luciano * chore: update ruff configuration to ignore E501 for alembic version files - Added a per-file ignore rule for E501 (line too long) in alembic version files to accommodate longer lines without triggering linting errors. This adjustment helps maintain code quality while allowing necessary flexibility in version scripts. * refactor: optimize body area data insertion in migration script - Replaced the bulk insert method with a direct SQL execution Co-authored-by: Rafael Luciano * fix(body-map): apply PR #24 review feedback Preserve test DB password in derived URL, validate upload file extensions, harden professional tenant checks when health_unit_id is missing, inject StorageService via Depends, and return 422 when patient_id is omitted. Co-authored-by: Cursor --------- Co-authored-by: Rafael Luciano Co-authored-by: Cursor --- .../alembic/versions/006_create_body_map.py | 254 ++++++++++++ backend/bruno/body_map/get_body_map.bru | 23 ++ backend/bruno/body_map/get_history.bru | 25 ++ backend/bruno/body_map/list_body_areas.bru | 23 ++ backend/bruno/body_map/update_body_map.bru | 46 +++ .../body_map/update_body_map_invalid_area.bru | 35 ++ backend/bruno/body_map/upload_image.bru | 38 ++ .../body_map/upload_image_invalid_type.bru | 30 ++ backend/pyproject.toml | 3 + backend/src/pequi/config.py | 6 +- backend/src/pequi/core/dependencies.py | 8 + backend/src/pequi/main.py | 3 + backend/src/pequi/models/__init__.py | 4 + backend/src/pequi/models/body_map.py | 158 ++++++++ .../src/pequi/repositories/body_map_repo.py | 153 ++++++++ backend/src/pequi/routers/body_map.py | 132 +++++++ backend/src/pequi/routers/checkin.py | 29 +- backend/src/pequi/schemas/body_map.py | 102 +++++ backend/src/pequi/services/storage_service.py | 39 ++ .../pequi/use_cases/get_body_map_history.py | 102 +++++ backend/src/pequi/use_cases/submit_checkin.py | 11 + .../src/pequi/use_cases/update_body_map.py | 183 +++++++++ backend/tests/integration/test_body_map.py | 371 ++++++++++++++++++ backend/tests/unit/test_body_map_schema.py | 52 +++ backend/tests/unit/test_body_map_use_cases.py | 79 ++++ 25 files changed, 1902 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/006_create_body_map.py create mode 100644 backend/bruno/body_map/get_body_map.bru create mode 100644 backend/bruno/body_map/get_history.bru create mode 100644 backend/bruno/body_map/list_body_areas.bru create mode 100644 backend/bruno/body_map/update_body_map.bru create mode 100644 backend/bruno/body_map/update_body_map_invalid_area.bru create mode 100644 backend/bruno/body_map/upload_image.bru create mode 100644 backend/bruno/body_map/upload_image_invalid_type.bru create mode 100644 backend/src/pequi/models/body_map.py create mode 100644 backend/src/pequi/repositories/body_map_repo.py create mode 100644 backend/src/pequi/routers/body_map.py create mode 100644 backend/src/pequi/schemas/body_map.py create mode 100644 backend/src/pequi/services/storage_service.py create mode 100644 backend/src/pequi/use_cases/get_body_map_history.py create mode 100644 backend/src/pequi/use_cases/update_body_map.py create mode 100644 backend/tests/integration/test_body_map.py create mode 100644 backend/tests/unit/test_body_map_schema.py create mode 100644 backend/tests/unit/test_body_map_use_cases.py diff --git a/backend/alembic/versions/006_create_body_map.py b/backend/alembic/versions/006_create_body_map.py new file mode 100644 index 0000000..3758e33 --- /dev/null +++ b/backend/alembic/versions/006_create_body_map.py @@ -0,0 +1,254 @@ +"""create body map tables — M5 Body Map + +Revision ID: 006_create_body_map +Revises: 005_create_checkins +Create Date: 2026-05-26 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "006_create_body_map" +down_revision: str | None = "005_create_checkins" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + body_side_enum = postgresql.ENUM( + "left", + "right", + "center", + "bilateral", + name="body_side_enum", + ) + body_system_part_enum = postgresql.ENUM( + "head", + "trunk", + "upper_limb", + "lower_limb", + name="body_system_part_enum", + ) + body_finding_type_enum = postgresql.ENUM( + "lesion", + "hypoesthesia", + "anesthesia", + "nodule", + "other", + name="body_finding_type_enum", + ) + + body_side_enum.create(op.get_bind(), checkfirst=True) + body_system_part_enum.create(op.get_bind(), checkfirst=True) + body_finding_type_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "body_areas", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("code", sa.Text(), nullable=False), + sa.Column("label", sa.Text(), nullable=False), + sa.Column( + "side", + postgresql.ENUM( + "left", + "right", + "center", + "bilateral", + name="body_side_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "system_part", + postgresql.ENUM( + "head", + "trunk", + "upper_limb", + "lower_limb", + name="body_system_part_enum", + create_type=False, + ), + nullable=False, + ), + sa.UniqueConstraint("code", name="uq_body_areas_code"), + ) + + op.create_index("ix_body_areas_system_part", "body_areas", ["system_part"]) + op.create_index("ix_body_areas_label", "body_areas", ["label"]) + + op.create_table( + "body_map_entries", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("body_area_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "finding_type", + postgresql.ENUM( + "lesion", + "hypoesthesia", + "anesthesia", + "nodule", + "other", + name="body_finding_type_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("intensity", sa.SmallInteger(), nullable=True), + sa.Column("image_url", sa.Text(), nullable=True), + sa.Column("image_key", sa.Text(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "recorded_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.CheckConstraint( + "intensity IS NULL OR (intensity >= 0 AND intensity <= 3)", + name="ck_body_map_entries_body_map_entries_intensity_range", + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + name="fk_body_map_entries_patient_id_patient_profiles", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["body_area_id"], + ["body_areas.id"], + name="fk_body_map_entries_body_area_id_body_areas", + ondelete="RESTRICT", + ), + ) + op.create_index("ix_body_map_entries_patient_id", "body_map_entries", ["patient_id"]) + op.create_index("ix_body_map_entries_body_area_id", "body_map_entries", ["body_area_id"]) + op.create_index("ix_body_map_entries_deleted_at", "body_map_entries", ["deleted_at"]) + op.create_index( + "uq_body_map_entries_active_patient_area", + "body_map_entries", + ["patient_id", "body_area_id"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + op.create_table( + "body_area_history", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("checkin_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("body_area_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "finding_type", + postgresql.ENUM( + "lesion", + "hypoesthesia", + "anesthesia", + "nodule", + "other", + name="body_finding_type_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("intensity", sa.SmallInteger(), nullable=True), + sa.Column("image_url", sa.Text(), nullable=True), + sa.Column("image_key", sa.Text(), nullable=True), + sa.Column( + "snapshot_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint( + "intensity IS NULL OR (intensity >= 0 AND intensity <= 3)", + name="ck_body_area_history_body_area_history_intensity_range", + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + name="fk_body_area_history_patient_id_patient_profiles", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["checkin_id"], + ["checkins.id"], + name="fk_body_area_history_checkin_id_checkins", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["body_area_id"], + ["body_areas.id"], + name="fk_body_area_history_body_area_id_body_areas", + ondelete="RESTRICT", + ), + ) + op.create_index("ix_body_area_history_patient_id", "body_area_history", ["patient_id"]) + op.create_index("ix_body_area_history_body_area_id", "body_area_history", ["body_area_id"]) + op.create_index("ix_body_area_history_snapshot_at", "body_area_history", ["snapshot_at"]) + + op.execute( + sa.text( + """ + INSERT INTO body_areas (id, code, label, side, system_part) VALUES + ('5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26','left_cheek','Bochecha esquerda','left','head'), + ('bcc5f7ce-2632-47f0-adf2-bbf22bd4ec8a','right_cheek','Bochecha direita','right','head'), + ('0de315f6-90d4-4637-a81f-ac2f34512bd7','forehead','Testa','center','head'), + ('760d7f14-e2df-4426-8f85-0f2750f176ef','nose','Nariz','center','head'), + ('b2fb1f7f-6542-4f4b-916c-092f95aa0088','left_ear','Orelha esquerda','left','head'), + ('df80aa7b-a0db-48e2-ae9a-662c2fd8a466','right_ear','Orelha direita','right','head'), + ('ce426de8-8108-4e4f-b06f-7e6a2177d7ab','chest','Tórax','center','trunk'), + ('f49fd957-89a8-46c5-b69f-4296a8f95784','upper_back','Dorso superior','center','trunk'), + ('64d0af93-f2d5-484f-a1a6-53d8f3778cad','abdomen','Abdômen','center','trunk'), + ('15f12935-ad64-44d3-a39a-b2fc1f614469','lower_back','Lombar','center','trunk'), + ('af6a2d96-dde2-4bd8-a67e-beb3a4122f4d','left_shoulder','Ombro esquerdo','left','upper_limb'), + ('ef2f6ed8-25d9-4df0-a6e3-83e85b3fd522','right_shoulder','Ombro direito','right','upper_limb'), + ('e4995804-017b-47bd-b91f-7a05eb1f6710','left_arm','Braço esquerdo','left','upper_limb'), + ('95c7800d-0099-4222-b28a-b4f68b80f473','right_arm','Braço direito','right','upper_limb'), + ('f6e8149e-a72f-4c0c-b44a-a7f5a64f7f26','left_forearm','Antebraço esquerdo','left','upper_limb'), + ('2db83785-7f37-4a6f-9fdc-8b4f8ea2cd8d','right_forearm','Antebraço direito','right','upper_limb'), + ('e8b845d9-8d28-4821-8409-f2b46f7d3e71','left_hand','Mão esquerda','left','upper_limb'), + ('4cfc2ea9-729f-45f1-a077-45e814d9ecf2','right_hand','Mão direita','right','upper_limb'), + ('eeb12cd1-e21b-4832-b84f-c87f8dca76f8','left_thigh','Coxa esquerda','left','lower_limb'), + ('9d495f41-6334-4e60-b7ff-ec76349fd319','right_thigh','Coxa direita','right','lower_limb'), + ('6ff9629e-c5c7-4cd2-ac17-95f40eeec54b','left_knee','Joelho esquerdo','left','lower_limb'), + ('c4bbf2ac-d58e-4917-90d3-f8fcd7a68eb8','right_knee','Joelho direito','right','lower_limb'), + ('8f42d97f-cbb8-4c7c-a2f9-dbe463aa6f57','left_leg','Perna esquerda','left','lower_limb'), + ('b1307f4a-1783-47bc-82f8-6d28242ee379','right_leg','Perna direita','right','lower_limb'), + ('337f9fd7-34dc-42e8-ba32-a5751945163b','left_foot','Pé esquerdo','left','lower_limb'), + ('b87c851f-5dcf-4fb7-8e0d-96f1f4d2af8c','right_foot','Pé direito','right','lower_limb') + """ + ) + ) + + +def downgrade() -> None: + op.drop_index("ix_body_area_history_snapshot_at", table_name="body_area_history") + op.drop_index("ix_body_area_history_body_area_id", table_name="body_area_history") + op.drop_index("ix_body_area_history_patient_id", table_name="body_area_history") + op.drop_table("body_area_history") + + op.drop_index("uq_body_map_entries_active_patient_area", table_name="body_map_entries") + op.drop_index("ix_body_map_entries_deleted_at", table_name="body_map_entries") + op.drop_index("ix_body_map_entries_body_area_id", table_name="body_map_entries") + op.drop_index("ix_body_map_entries_patient_id", table_name="body_map_entries") + op.drop_table("body_map_entries") + + op.drop_index("ix_body_areas_label", table_name="body_areas") + op.drop_index("ix_body_areas_system_part", table_name="body_areas") + op.drop_table("body_areas") + + sa.Enum(name="body_finding_type_enum").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="body_system_part_enum").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="body_side_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/bruno/body_map/get_body_map.bru b/backend/bruno/body_map/get_body_map.bru new file mode 100644 index 0000000..c38ea76 --- /dev/null +++ b/backend/bruno/body_map/get_body_map.bru @@ -0,0 +1,23 @@ +meta { + name: Get Body Map + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/v1/body-map + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 +} + +docs { + Retorna o mapa corporal atual (somente entradas ativas) do paciente autenticado. +} diff --git a/backend/bruno/body_map/get_history.bru b/backend/bruno/body_map/get_history.bru new file mode 100644 index 0000000..26d2c5f --- /dev/null +++ b/backend/bruno/body_map/get_history.bru @@ -0,0 +1,25 @@ +meta { + name: Get Body Map History + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v1/body-map/history?body_area_id={{bodyAreaId}}&finding_type=lesion + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 +} + +docs { + Histórico imutável de snapshots do body map. + Para profissionais, informar patient_id no query string. + Filtros opcionais de data: from_date e to_date em YYYY-MM-DD (UTC). +} diff --git a/backend/bruno/body_map/list_body_areas.bru b/backend/bruno/body_map/list_body_areas.bru new file mode 100644 index 0000000..03ea219 --- /dev/null +++ b/backend/bruno/body_map/list_body_areas.bru @@ -0,0 +1,23 @@ +meta { + name: List Body Areas + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/v1/body-areas + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 +} + +docs { + Catálogo fixo de áreas corporais ordenado por system_part e label. +} diff --git a/backend/bruno/body_map/update_body_map.bru b/backend/bruno/body_map/update_body_map.bru new file mode 100644 index 0000000..a8e0c53 --- /dev/null +++ b/backend/bruno/body_map/update_body_map.bru @@ -0,0 +1,46 @@ +meta { + name: Update Body Map + type: http + seq: 2 +} + +put { + url: {{baseUrl}}/v1/body-map + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "entries": [ + { + "body_area_id": "{{bodyAreaId}}", + "finding_type": "lesion", + "intensity": 2, + "notes": "Placa hipocrômica" + }, + { + "body_area_id": "{{bodyAreaIdToRemove}}", + "finding_type": "other", + "remove": true + } + ] + } +} + +assert { + res.status: eq 200 +} + +docs { + Upsert transacional do mapa corporal atual. + remove=true aplica soft delete da marcação ativa para a área. +} diff --git a/backend/bruno/body_map/update_body_map_invalid_area.bru b/backend/bruno/body_map/update_body_map_invalid_area.bru new file mode 100644 index 0000000..8299e7b --- /dev/null +++ b/backend/bruno/body_map/update_body_map_invalid_area.bru @@ -0,0 +1,35 @@ +meta { + name: Update Body Map Invalid Area + type: http + seq: 6 +} + +put { + url: {{baseUrl}}/v1/body-map + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "entries": [ + { + "body_area_id": "00000000-0000-0000-0000-000000000000", + "finding_type": "lesion", + "intensity": 2 + } + ] + } +} + +assert { + res.status: eq 404 +} diff --git a/backend/bruno/body_map/upload_image.bru b/backend/bruno/body_map/upload_image.bru new file mode 100644 index 0000000..34f768c --- /dev/null +++ b/backend/bruno/body_map/upload_image.bru @@ -0,0 +1,38 @@ +meta { + name: Upload Body Map Image + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/v1/body-map/upload + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "filename": "lesao-mao.png", + "content_type": "image/png" + } +} + +assert { + res.status: eq 200 + res.body.upload_url: isDefined + res.body.file_key: isDefined + res.body.public_url: isDefined +} + +docs { + Gera URL pré-assinada para upload de imagem do body map. + Backend não recebe bytes nem salva blob em PostgreSQL. +} diff --git a/backend/bruno/body_map/upload_image_invalid_type.bru b/backend/bruno/body_map/upload_image_invalid_type.bru new file mode 100644 index 0000000..9a62164 --- /dev/null +++ b/backend/bruno/body_map/upload_image_invalid_type.bru @@ -0,0 +1,30 @@ +meta { + name: Upload Body Map Invalid Type + type: http + seq: 7 +} + +post { + url: {{baseUrl}}/v1/body-map/upload + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "filename": "lesao.txt", + "content_type": "text/plain" + } +} + +assert { + res.status: eq 422 +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2189859..f0e4223 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -83,5 +83,8 @@ ignore = [ "ARG001", # unused function argument (common in FastAPI route handlers) ] +[tool.ruff.lint.per-file-ignores] +"alembic/versions/*.py" = ["E501"] + [tool.ruff.lint.isort] known-first-party = ["pequi"] diff --git a/backend/src/pequi/config.py b/backend/src/pequi/config.py index 7ada64d..9cadf1e 100644 --- a/backend/src/pequi/config.py +++ b/backend/src/pequi/config.py @@ -76,7 +76,11 @@ def get_test_database_url(self) -> str: """ if self.DATABASE_URL_TEST: return self.DATABASE_URL_TEST - return str(make_url(self.DATABASE_URL).set(database="pequi_test")) + return ( + make_url(self.DATABASE_URL) + .set(database="pequi_test") + .render_as_string(hide_password=False) + ) @lru_cache diff --git a/backend/src/pequi/core/dependencies.py b/backend/src/pequi/core/dependencies.py index d562650..d535342 100644 --- a/backend/src/pequi/core/dependencies.py +++ b/backend/src/pequi/core/dependencies.py @@ -12,10 +12,12 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession +from pequi.config import get_settings from pequi.core.auth import TOKEN_TYPE_ACCESS, JWTError, decode_token from pequi.core.exceptions import ForbiddenError, UnauthorizedError from pequi.database import get_db as _get_db from pequi.repositories.patient_repo import PatientRepository +from pequi.services.storage_service import FakeStorageService, StorageService from pequi.use_cases.get_patient_profile import GetPatientProfileUseCase from pequi.use_cases.update_patient_profile import UpdatePatientProfileUseCase @@ -92,6 +94,11 @@ async def get_update_patient_profile_use_case( return UpdatePatientProfileUseCase(PatientRepository(session)) +def get_storage_service() -> StorageService: + settings = get_settings() + return FakeStorageService(base_url=settings.STORAGE_PUBLIC_URL) + + __all__ = [ "get_db", "get_token_payload", @@ -102,4 +109,5 @@ async def get_update_patient_profile_use_case( "get_current_admin", "get_patient_profile_use_case", "get_update_patient_profile_use_case", + "get_storage_service", ] diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 5148e40..efa91bb 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -67,6 +67,7 @@ async def health_check() -> JSONResponse: app.include_router(health_router) from pequi.routers import auth as auth_router + from pequi.routers import body_map as body_map_router from pequi.routers import checkin as checkin_router from pequi.routers import patient as patient_router from pequi.routers import treatment as treatment_router @@ -77,6 +78,8 @@ async def health_check() -> JSONResponse: app.include_router(treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"]) app.include_router(checkin_router.router, prefix="/v1/checkins", tags=["checkins"]) app.include_router(checkin_router.alerts_router, prefix="/v1/alerts", tags=["alerts"]) + app.include_router(body_map_router.router, prefix="/v1/body-map", tags=["body-map"]) + app.include_router(body_map_router.areas_router, prefix="/v1/body-areas", tags=["body-map"]) app = create_app() diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 020cb01..08316fa 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -1,4 +1,5 @@ from pequi.models.alert import Alert +from pequi.models.body_map import BodyArea, BodyAreaHistory, BodyMapEntry from pequi.models.checkin import Checkin from pequi.models.consent import Consent from pequi.models.dose_log import AdherenceSnapshot, DoseLog @@ -12,6 +13,9 @@ __all__ = [ "AdherenceSnapshot", "Alert", + "BodyArea", + "BodyAreaHistory", + "BodyMapEntry", "Checkin", "Consent", "DoseLog", diff --git a/backend/src/pequi/models/body_map.py b/backend/src/pequi/models/body_map.py new file mode 100644 index 0000000..7555238 --- /dev/null +++ b/backend/src/pequi/models/body_map.py @@ -0,0 +1,158 @@ +import uuid +from enum import StrEnum + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + Enum, + ForeignKey, + Index, + SmallInteger, + Text, + text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from pequi.database import Base + + +class BodySide(StrEnum): + left = "left" + right = "right" + center = "center" + bilateral = "bilateral" + + +class BodySystemPart(StrEnum): + head = "head" + trunk = "trunk" + upper_limb = "upper_limb" + lower_limb = "lower_limb" + + +class BodyFindingType(StrEnum): + lesion = "lesion" + hypoesthesia = "hypoesthesia" + anesthesia = "anesthesia" + nodule = "nodule" + other = "other" + + +class BodyArea(Base): + __tablename__ = "body_areas" + __table_args__ = ( + Index("ix_body_areas_system_part", "system_part"), + Index("ix_body_areas_label", "label"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + code = Column(Text, nullable=False, unique=True) + label = Column(Text, nullable=False) + side = Column( + Enum(BodySide, name="body_side_enum"), + nullable=False, + ) + system_part = Column( + Enum(BodySystemPart, name="body_system_part_enum"), + nullable=False, + ) + + +class BodyMapEntry(Base): + __tablename__ = "body_map_entries" + __table_args__ = ( + CheckConstraint( + "intensity IS NULL OR (intensity >= 0 AND intensity <= 3)", + name="body_map_entries_intensity_range", + ), + Index("ix_body_map_entries_patient_id", "patient_id"), + Index("ix_body_map_entries_body_area_id", "body_area_id"), + Index("ix_body_map_entries_deleted_at", "deleted_at"), + Index( + "uq_body_map_entries_active_patient_area", + "patient_id", + "body_area_id", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + body_area_id = Column( + UUID(as_uuid=True), + ForeignKey("body_areas.id", ondelete="RESTRICT"), + nullable=False, + ) + finding_type = Column( + Enum(BodyFindingType, name="body_finding_type_enum"), + nullable=False, + ) + intensity = Column(SmallInteger, nullable=True) + image_url = Column(Text, nullable=True) + image_key = Column(Text, nullable=True) + notes = Column(Text, nullable=True) + recorded_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + body_area = relationship("BodyArea", lazy="joined") + + +class BodyAreaHistory(Base): + __tablename__ = "body_area_history" + __table_args__ = ( + CheckConstraint( + "intensity IS NULL OR (intensity >= 0 AND intensity <= 3)", + name="body_area_history_intensity_range", + ), + Index("ix_body_area_history_patient_id", "patient_id"), + Index("ix_body_area_history_body_area_id", "body_area_id"), + Index("ix_body_area_history_snapshot_at", "snapshot_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + checkin_id = Column( + UUID(as_uuid=True), + ForeignKey("checkins.id", ondelete="RESTRICT"), + nullable=True, + ) + body_area_id = Column( + UUID(as_uuid=True), + ForeignKey("body_areas.id", ondelete="RESTRICT"), + nullable=False, + ) + finding_type = Column( + Enum(BodyFindingType, name="body_finding_type_enum"), + nullable=False, + ) + intensity = Column(SmallInteger, nullable=True) + image_url = Column(Text, nullable=True) + image_key = Column(Text, nullable=True) + snapshot_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + body_area = relationship("BodyArea", lazy="joined") diff --git a/backend/src/pequi/repositories/body_map_repo.py b/backend/src/pequi/repositories/body_map_repo.py new file mode 100644 index 0000000..bf637cf --- /dev/null +++ b/backend/src/pequi/repositories/body_map_repo.py @@ -0,0 +1,153 @@ +from collections.abc import Sequence +from datetime import datetime +from uuid import UUID + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.body_map import BodyArea, BodyAreaHistory, BodyFindingType, BodyMapEntry + + +class BodyMapRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_body_areas(self) -> list[BodyArea]: + stmt = select(BodyArea).order_by(BodyArea.system_part, BodyArea.label) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_body_areas_by_ids(self, ids: Sequence[UUID]) -> list[BodyArea]: + if not ids: + return [] + stmt = select(BodyArea).where(BodyArea.id.in_(ids)) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def list_active_entries_by_patient(self, patient_id: UUID) -> list[BodyMapEntry]: + stmt = ( + select(BodyMapEntry) + .where( + BodyMapEntry.patient_id == patient_id, + BodyMapEntry.deleted_at.is_(None), + ) + .order_by(BodyMapEntry.recorded_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_active_entry_by_patient_and_area( + self, + patient_id: UUID, + body_area_id: UUID, + ) -> BodyMapEntry | None: + stmt = select(BodyMapEntry).where( + BodyMapEntry.patient_id == patient_id, + BodyMapEntry.body_area_id == body_area_id, + BodyMapEntry.deleted_at.is_(None), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def create_or_update_entry( + self, + *, + patient_id: UUID, + body_area_id: UUID, + finding_type: BodyFindingType, + intensity: int | None, + image_url: str | None, + image_key: str | None, + notes: str | None, + recorded_at: datetime, + ) -> BodyMapEntry: + entry = await self.get_active_entry_by_patient_and_area(patient_id, body_area_id) + if entry is None: + entry = BodyMapEntry( + patient_id=patient_id, + body_area_id=body_area_id, + finding_type=finding_type, + intensity=intensity, + image_url=image_url, + image_key=image_key, + notes=notes, + recorded_at=recorded_at, + ) + self._session.add(entry) + else: + entry.finding_type = finding_type + entry.intensity = intensity + entry.image_url = image_url + entry.image_key = image_key + entry.notes = notes + entry.recorded_at = recorded_at + + await self._session.flush() + await self._session.refresh(entry) + return entry + + async def soft_delete_by_patient_and_area( + self, + patient_id: UUID, + body_area_id: UUID, + *, + deleted_at: datetime, + ) -> BodyMapEntry | None: + entry = await self.get_active_entry_by_patient_and_area(patient_id, body_area_id) + if entry is None: + return None + entry.deleted_at = deleted_at + await self._session.flush() + return entry + + async def create_history_from_entries( + self, + *, + patient_id: UUID, + checkin_id: UUID | None, + entries: Sequence[BodyMapEntry], + snapshot_at: datetime, + ) -> list[BodyAreaHistory]: + history_rows = [ + BodyAreaHistory( + patient_id=patient_id, + checkin_id=checkin_id, + body_area_id=entry.body_area_id, + finding_type=entry.finding_type, + intensity=entry.intensity, + image_url=entry.image_url, + image_key=entry.image_key, + snapshot_at=snapshot_at, + ) + for entry in entries + ] + self._session.add_all(history_rows) + await self._session.flush() + return history_rows + + async def list_history_by_patient( + self, + patient_id: UUID, + *, + body_area_id: UUID | None = None, + finding_type: BodyFindingType | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + ) -> list[BodyAreaHistory]: + filters = [BodyAreaHistory.patient_id == patient_id] + if body_area_id is not None: + filters.append(BodyAreaHistory.body_area_id == body_area_id) + if finding_type is not None: + filters.append(BodyAreaHistory.finding_type == finding_type) + if from_date is not None: + filters.append(BodyAreaHistory.snapshot_at >= from_date) + if to_date is not None: + filters.append(BodyAreaHistory.snapshot_at <= to_date) + + stmt = ( + select(BodyAreaHistory) + .where(and_(*filters)) + .order_by(BodyAreaHistory.snapshot_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/pequi/routers/body_map.py b/backend/src/pequi/routers/body_map.py new file mode 100644 index 0000000..ce80afa --- /dev/null +++ b/backend/src/pequi/routers/body_map.py @@ -0,0 +1,132 @@ +from datetime import date +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import ( + get_actor_from_token, + get_current_patient, + get_current_user, + get_db, + get_storage_service, +) +from pequi.core.rate_limit import user_limiter +from pequi.models.body_map import BodyFindingType +from pequi.repositories.body_map_repo import BodyMapRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.body_map import ( + BodyAreaResponse, + BodyMapEntryResponse, + BodyMapHistoryResponse, + BodyMapUpdateRequest, + BodyMapUploadRequest, + UploadUrlResponse, +) +from pequi.services.storage_service import StorageService +from pequi.use_cases.get_body_map_history import GetBodyMapHistoryUseCase +from pequi.use_cases.update_body_map import ( + GenerateBodyMapUploadUrlUseCase, + GetBodyMapUseCase, + ListBodyAreasUseCase, + UpdateBodyMapUseCase, +) + +router = APIRouter() +areas_router = APIRouter() + + +def _repos( + session: AsyncSession, +) -> tuple[BodyMapRepository, PatientRepository, HealthProfessionalRepository]: + return ( + BodyMapRepository(session), + PatientRepository(session), + HealthProfessionalRepository(session), + ) + + +@router.get("", response_model=list[BodyMapEntryResponse]) +@user_limiter.limit("100/minute") +async def get_body_map( + request: Request, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> list[BodyMapEntryResponse]: + body_map_repo, patient_repo, _ = _repos(session) + use_case = GetBodyMapUseCase(body_map_repo, patient_repo) + return await use_case.execute(patient_user_id) + + +@router.put("", response_model=list[BodyMapEntryResponse]) +@user_limiter.limit("20/minute") +async def update_body_map( + request: Request, + payload: BodyMapUpdateRequest, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> list[BodyMapEntryResponse]: + body_map_repo, patient_repo, _ = _repos(session) + use_case = UpdateBodyMapUseCase(body_map_repo, patient_repo) + return await use_case.execute(patient_user_id, payload) + + +@router.get("/history", response_model=list[BodyMapHistoryResponse]) +@user_limiter.limit("100/minute") +async def get_body_map_history( + request: Request, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), + patient_id: UUID | None = Query(default=None), + body_area_id: UUID | None = Query(default=None), + finding_type: BodyFindingType | None = Query(default=None), + from_date: date | None = Query(default=None), + to_date: date | None = Query(default=None), +) -> list[BodyMapHistoryResponse]: + actor_user_id, actor_role = actor + body_map_repo, patient_repo, professional_repo = _repos(session) + use_case = GetBodyMapHistoryUseCase(body_map_repo, patient_repo, professional_repo) + return await use_case.execute( + actor_user_id, + actor_role, + patient_id=patient_id, + body_area_id=body_area_id, + finding_type=finding_type, + from_date=from_date, + to_date=to_date, + ) + + +@router.post("/upload", response_model=UploadUrlResponse) +@user_limiter.limit("5/minute") +async def create_body_map_upload_url( + request: Request, + payload: BodyMapUploadRequest, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), + storage_service: StorageService = Depends(get_storage_service), +) -> UploadUrlResponse: + _, patient_repo, _ = _repos(session) + use_case = GenerateBodyMapUploadUrlUseCase( + patient_repo, + storage_service=storage_service, + ) + return await use_case.execute( + patient_user_id, + filename=payload.filename, + content_type=payload.content_type, + ) + + +@areas_router.get("", response_model=list[BodyAreaResponse]) +@user_limiter.limit("200/minute") +async def list_body_areas( + request: Request, + _user_id: UUID = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +) -> list[BodyAreaResponse]: + body_map_repo, _, _ = _repos(session) + use_case = ListBodyAreasUseCase(body_map_repo) + body_areas = await use_case.execute() + return [BodyAreaResponse.model_validate(area) for area in body_areas] diff --git a/backend/src/pequi/routers/checkin.py b/backend/src/pequi/routers/checkin.py index 0aa16eb..3dbfaa1 100644 --- a/backend/src/pequi/routers/checkin.py +++ b/backend/src/pequi/routers/checkin.py @@ -11,6 +11,7 @@ ) from pequi.core.rate_limit import user_limiter from pequi.repositories.alert_repo import AlertRepository +from pequi.repositories.body_map_repo import BodyMapRepository from pequi.repositories.checkin_repo import CheckinRepository from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_professional_repo import HealthProfessionalRepository @@ -39,6 +40,7 @@ def _checkin_repos( AlertRepository, DoseRepository, HealthProfessionalRepository, + BodyMapRepository, ]: return ( CheckinRepository(session), @@ -47,6 +49,7 @@ def _checkin_repos( AlertRepository(session), DoseRepository(session), HealthProfessionalRepository(session), + BodyMapRepository(session), ) @@ -59,9 +62,23 @@ async def submit_checkin( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> CheckinResponse: - checkin_repo, patient_repo, symptom_repo, alert_repo, dose_repo, _ = _checkin_repos(session) + ( + checkin_repo, + patient_repo, + symptom_repo, + alert_repo, + dose_repo, + _, + body_map_repo, + ) = _checkin_repos(session) alert_service = AlertService(alert_repo, checkin_repo, dose_repo) - use_case = SubmitCheckinUseCase(checkin_repo, patient_repo, symptom_repo, alert_service) + use_case = SubmitCheckinUseCase( + checkin_repo, + patient_repo, + symptom_repo, + alert_service, + body_map_repo, + ) result = await use_case.execute(user_id, body) # Enqueue AI feedback after transaction commit (runs after response is sent) @@ -91,7 +108,7 @@ async def get_checkin_history( limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), ) -> CheckinListResponse: - checkin_repo, patient_repo, _, _, _, _ = _checkin_repos(session) + checkin_repo, patient_repo, _, _, _, _, _ = _checkin_repos(session) use_case = GetCheckinHistoryUseCase(checkin_repo, patient_repo) return await use_case.execute(user_id, limit=limit, offset=offset) @@ -105,7 +122,7 @@ async def get_checkin( session: AsyncSession = Depends(get_db), ) -> CheckinResponse: actor_user_id, actor_role = actor - checkin_repo, patient_repo, _, _, _, professional_repo = _checkin_repos(session) + checkin_repo, patient_repo, _, _, _, professional_repo, _ = _checkin_repos(session) use_case = GetCheckinUseCase(checkin_repo, patient_repo, professional_repo) return await use_case.execute(actor_user_id, actor_role, checkin_id) @@ -122,7 +139,7 @@ async def list_alerts( offset: int = Query(default=0, ge=0), ) -> AlertListResponse: actor_user_id, actor_role = actor - _, patient_repo, _, alert_repo, _, professional_repo = _checkin_repos(session) + _, patient_repo, _, alert_repo, _, professional_repo, _ = _checkin_repos(session) use_case = ListAlertsUseCase(alert_repo, patient_repo, professional_repo) return await use_case.execute( actor_user_id, @@ -143,6 +160,6 @@ async def resolve_alert( professional_user_id: UUID = Depends(get_current_professional), session: AsyncSession = Depends(get_db), ) -> AlertResponse: - _, patient_repo, _, alert_repo, _, professional_repo = _checkin_repos(session) + _, patient_repo, _, alert_repo, _, professional_repo, _ = _checkin_repos(session) use_case = ResolveAlertUseCase(alert_repo, patient_repo, professional_repo) return await use_case.execute(professional_user_id, alert_id, body) diff --git a/backend/src/pequi/schemas/body_map.py b/backend/src/pequi/schemas/body_map.py new file mode 100644 index 0000000..603fd7c --- /dev/null +++ b/backend/src/pequi/schemas/body_map.py @@ -0,0 +1,102 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart + + +class BodyAreaResponse(BaseModel): + id: UUID + code: str + label: str + side: BodySide + system_part: BodySystemPart + + model_config = ConfigDict(from_attributes=True) + + +class BodyMapEntryBase(BaseModel): + body_area_id: UUID + finding_type: BodyFindingType | None = None + intensity: int | None = Field(default=None, ge=0, le=3) + image_url: str | None = Field(default=None, max_length=2048) + image_key: str | None = Field(default=None, max_length=512) + notes: str | None = Field(default=None, max_length=1000) + + @field_validator("image_url", "image_key", "notes") + @classmethod + def _strip_nullable_text(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +class BodyMapEntryCreate(BodyMapEntryBase): + finding_type: BodyFindingType + + model_config = ConfigDict(extra="forbid") + + +class BodyMapEntryUpdate(BodyMapEntryBase): + remove: bool = False + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def _validate_required_fields(self) -> "BodyMapEntryUpdate": + if not self.remove and self.finding_type is None: + msg = "finding_type é obrigatório quando remove=false." + raise ValueError(msg) + return self + + +class BodyMapUpdateRequest(BaseModel): + entries: list[BodyMapEntryUpdate] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class BodyMapEntryResponse(BaseModel): + id: UUID + patient_id: UUID + body_area_id: UUID + body_area: BodyAreaResponse + finding_type: BodyFindingType + intensity: int | None + image_url: str | None + image_key: str | None + notes: str | None + recorded_at: datetime + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class BodyMapHistoryResponse(BaseModel): + id: UUID + patient_id: UUID + checkin_id: UUID | None + body_area_id: UUID + body_area: BodyAreaResponse + finding_type: BodyFindingType + intensity: int | None + image_url: str | None + image_key: str | None + snapshot_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class UploadUrlResponse(BaseModel): + upload_url: str + file_key: str + public_url: str + + +class BodyMapUploadRequest(BaseModel): + filename: str = Field(min_length=3, max_length=255) + content_type: str = Field(min_length=3, max_length=100) + + model_config = ConfigDict(extra="forbid") diff --git a/backend/src/pequi/services/storage_service.py b/backend/src/pequi/services/storage_service.py new file mode 100644 index 0000000..5f79f5e --- /dev/null +++ b/backend/src/pequi/services/storage_service.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID, uuid4 + + +@dataclass(frozen=True) +class PresignedUpload: + upload_url: str + file_key: str + public_url: str + + +class StorageService(Protocol): + async def generate_body_map_upload_url( + self, + *, + patient_id: UUID, + content_type: str, + extension: str, + ) -> PresignedUpload: ... + + +class FakeStorageService: + def __init__(self, *, base_url: str = "https://storage.mock.local") -> None: + self._base_url = base_url.rstrip("/") + + async def generate_body_map_upload_url( + self, + *, + patient_id: UUID, + content_type: str, + extension: str, + ) -> PresignedUpload: + del content_type # reservado para validação mais forte com provider real no M10 + object_id = uuid4() + file_key = f"body-map/{patient_id}/{object_id}.{extension}" + public_url = f"{self._base_url}/public/{file_key}" + upload_url = f"{self._base_url}/upload/{file_key}?signature=fake-signature" + return PresignedUpload(upload_url=upload_url, file_key=file_key, public_url=public_url) diff --git a/backend/src/pequi/use_cases/get_body_map_history.py b/backend/src/pequi/use_cases/get_body_map_history.py new file mode 100644 index 0000000..101c30b --- /dev/null +++ b/backend/src/pequi/use_cases/get_body_map_history.py @@ -0,0 +1,102 @@ +from datetime import UTC, date, datetime, time +from uuid import UUID + +from pequi.core.exceptions import ForbiddenError, NotFoundError, ValidationFailedError +from pequi.core.logging import get_logger +from pequi.models.body_map import BodyFindingType +from pequi.repositories.body_map_repo import BodyMapRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.body_map import BodyMapHistoryResponse + +logger = get_logger(__name__) + + +class GetBodyMapHistoryUseCase: + def __init__( + self, + body_map_repo: BodyMapRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._body_map_repo = body_map_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: UUID, + actor_role: str, + *, + patient_id: UUID | None = None, + body_area_id: UUID | None = None, + finding_type: BodyFindingType | None = None, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[BodyMapHistoryResponse]: + target_patient_id = await self._resolve_patient_id(actor_user_id, actor_role, patient_id) + from_snapshot_at = ( + datetime.combine(from_date, time.min, tzinfo=UTC) if from_date is not None else None + ) + to_snapshot_at = ( + datetime.combine(to_date, time.max.replace(microsecond=999999), tzinfo=UTC) + if to_date is not None + else None + ) + items = await self._body_map_repo.list_history_by_patient( + target_patient_id, + body_area_id=body_area_id, + finding_type=finding_type, + from_date=from_snapshot_at, + to_date=to_snapshot_at, + ) + return [BodyMapHistoryResponse.model_validate(item) for item in items] + + async def _resolve_patient_id( + self, + actor_user_id: UUID, + actor_role: str, + patient_id: UUID | None, + ) -> UUID: + if actor_role == "patient": + patient = await self._patient_repo.get_by_user_id(actor_user_id) + if patient is None: + raise NotFoundError("PatientProfile") + return patient.id + + if actor_role == "health_professional": + if patient_id is None: + raise ValidationFailedError( + "Profissional deve informar patient_id para consultar histórico." + ) + + professional = await self._professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await self._patient_repo.get_by_id(patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(patient_id)) + + if ( + not patient.health_unit_id + or not professional.health_unit_id + or patient.health_unit_id != professional.health_unit_id + ): + logger.warning( + "body_map.forbidden_cross_tenant_access", + professional_user_id=str(actor_user_id), + patient_id=str(patient_id), + ) + raise ForbiddenError( + "Profissional não tem acesso ao histórico de pacientes de outra unidade." + ) + + logger.info( + "audit.body_map_history.accessed_by_professional", + professional_user_id=str(actor_user_id), + patient_id=str(patient_id), + ) + return patient.id + + raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/submit_checkin.py b/backend/src/pequi/use_cases/submit_checkin.py index 23adeb0..9f67217 100644 --- a/backend/src/pequi/use_cases/submit_checkin.py +++ b/backend/src/pequi/use_cases/submit_checkin.py @@ -5,11 +5,13 @@ from pequi.core.exceptions import ConflictError, NotFoundError, ValidationFailedError from pequi.core.logging import get_logger +from pequi.repositories.body_map_repo import BodyMapRepository from pequi.repositories.checkin_repo import CheckinRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import SymptomRepository from pequi.schemas.checkin import CheckinCreate, CheckinResponse, checkin_to_response from pequi.services.alert_service import AlertService +from pequi.use_cases.update_body_map import create_body_map_snapshot logger = get_logger(__name__) _AI_FEEDBACK_INTENSITY_THRESHOLD = 7 @@ -23,11 +25,13 @@ def __init__( patient_repo: PatientRepository, symptom_repo: SymptomRepository, alert_service: AlertService, + body_map_repo: BodyMapRepository | None = None, ) -> None: self._checkin_repo = checkin_repo self._patient_repo = patient_repo self._symptom_repo = symptom_repo self._alert_service = alert_service + self._body_map_repo = body_map_repo async def execute(self, user_id: UUID, data: CheckinCreate) -> CheckinResponse: patient = await self._patient_repo.get_by_user_id(user_id) @@ -57,5 +61,12 @@ async def execute(self, user_id: UUID, data: CheckinCreate) -> CheckinResponse: raise ValidationFailedError("symptom_ids não pode conter duplicatas.") from exc raise + if self._body_map_repo is not None: + await create_body_map_snapshot( + body_map_repo=self._body_map_repo, + patient_id=patient.id, + checkin_id=checkin.id, + ) + await self._alert_service.evaluate_after_checkin(checkin) return checkin_to_response(checkin) diff --git a/backend/src/pequi/use_cases/update_body_map.py b/backend/src/pequi/use_cases/update_body_map.py new file mode 100644 index 0000000..ceccd07 --- /dev/null +++ b/backend/src/pequi/use_cases/update_body_map.py @@ -0,0 +1,183 @@ +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID + +from pequi.core.exceptions import NotFoundError, ValidationFailedError +from pequi.core.logging import get_logger +from pequi.repositories.body_map_repo import BodyMapRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.body_map import ( + BodyMapEntryResponse, + BodyMapUpdateRequest, + UploadUrlResponse, +) +from pequi.services.storage_service import StorageService + +logger = get_logger(__name__) +_ALLOWED_UPLOAD_CONTENT_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"} +_ALLOWED_UPLOAD_EXTENSIONS = {"jpeg", "jpg", "png", "webp"} + + +class GetBodyMapUseCase: + def __init__( + self, + body_map_repo: BodyMapRepository, + patient_repo: PatientRepository, + ) -> None: + self._body_map_repo = body_map_repo + self._patient_repo = patient_repo + + async def execute(self, patient_user_id: UUID) -> list[BodyMapEntryResponse]: + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + entries = await self._body_map_repo.list_active_entries_by_patient(patient.id) + return [BodyMapEntryResponse.model_validate(entry) for entry in entries] + + +class UpdateBodyMapUseCase: + def __init__( + self, + body_map_repo: BodyMapRepository, + patient_repo: PatientRepository, + ) -> None: + self._body_map_repo = body_map_repo + self._patient_repo = patient_repo + + async def execute( + self, + patient_user_id: UUID, + payload: BodyMapUpdateRequest, + ) -> list[BodyMapEntryResponse]: + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + body_area_ids = [entry.body_area_id for entry in payload.entries] + if len(body_area_ids) != len(set(body_area_ids)): + raise ValidationFailedError("entries não pode conter body_area_id duplicado.") + + existing_areas = await self._body_map_repo.get_body_areas_by_ids(body_area_ids) + existing_area_ids = {area.id for area in existing_areas} + invalid_ids = [area_id for area_id in body_area_ids if area_id not in existing_area_ids] + if invalid_ids: + raise NotFoundError("BodyArea", str(invalid_ids[0])) + + changed_count = 0 + now = datetime.now(UTC) + for item in payload.entries: + if item.remove: + await self._body_map_repo.soft_delete_by_patient_and_area( + patient.id, + item.body_area_id, + deleted_at=now, + ) + changed_count += 1 + continue + + finding_type = item.finding_type + if finding_type is None: + raise ValidationFailedError("finding_type é obrigatório para upsert.") + + await self._body_map_repo.create_or_update_entry( + patient_id=patient.id, + body_area_id=item.body_area_id, + finding_type=finding_type, + intensity=item.intensity, + image_url=item.image_url, + image_key=item.image_key, + notes=item.notes, + recorded_at=now, + ) + changed_count += 1 + + logger.info( + "body_map.updated", + patient_id=str(patient.id), + changed_entries=changed_count, + ) + + entries = await self._body_map_repo.list_active_entries_by_patient(patient.id) + return [BodyMapEntryResponse.model_validate(entry) for entry in entries] + + +class ListBodyAreasUseCase: + def __init__(self, body_map_repo: BodyMapRepository) -> None: + self._body_map_repo = body_map_repo + + async def execute(self) -> list: + return await self._body_map_repo.list_body_areas() + + +class GenerateBodyMapUploadUrlUseCase: + def __init__( + self, + patient_repo: PatientRepository, + storage_service: StorageService, + ) -> None: + self._patient_repo = patient_repo + self._storage_service = storage_service + + async def execute( + self, + patient_user_id: UUID, + *, + filename: str, + content_type: str, + ) -> UploadUrlResponse: + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + normalized_content_type = content_type.lower().strip() + if normalized_content_type not in _ALLOWED_UPLOAD_CONTENT_TYPES: + raise ValidationFailedError("content_type inválido para upload de imagem.") + + extension = Path(filename).suffix.lower().replace(".", "") + if not extension: + raise ValidationFailedError("filename deve conter extensão de arquivo.") + if extension not in _ALLOWED_UPLOAD_EXTENSIONS: + raise ValidationFailedError("Extensão de arquivo não permitida.") + + upload = await self._storage_service.generate_body_map_upload_url( + patient_id=patient.id, + content_type=normalized_content_type, + extension=extension, + ) + logger.info( + "body_map.upload_url_generated", + patient_id=str(patient.id), + file_key=upload.file_key, + ) + return UploadUrlResponse( + upload_url=upload.upload_url, + file_key=upload.file_key, + public_url=upload.public_url, + ) + + +async def create_body_map_snapshot( + *, + body_map_repo: BodyMapRepository, + patient_id: UUID, + checkin_id: UUID | None, +) -> int: + entries = await body_map_repo.list_active_entries_by_patient(patient_id) + if not entries: + return 0 + + snapshot_at = datetime.now(UTC) + created_rows = await body_map_repo.create_history_from_entries( + patient_id=patient_id, + checkin_id=checkin_id, + entries=entries, + snapshot_at=snapshot_at, + ) + logger.info( + "body_map.snapshot_created", + patient_id=str(patient_id), + checkin_id=str(checkin_id) if checkin_id else None, + count=len(created_rows), + ) + return len(created_rows) diff --git a/backend/tests/integration/test_body_map.py b/backend/tests/integration/test_body_map.py new file mode 100644 index 0000000..6f3dad5 --- /dev/null +++ b/backend/tests/integration/test_body_map.py @@ -0,0 +1,371 @@ +from datetime import date +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.auth import create_access_token +from pequi.models.body_map import BodyArea, BodySide, BodySystemPart +from pequi.models.symptom import Symptom, SymptomCategory +from tests.integration.test_dose_flow import ( + _create_health_unit, + _create_patient, + _create_professional, + _create_user, +) + +pytestmark = pytest.mark.asyncio + + +def _auth_headers(user_id, role: str) -> dict[str, str]: + token = create_access_token(subject=user_id, role=role) + return {"Authorization": f"Bearer {token}"} + + +async def _create_body_area( + session: AsyncSession, + *, + code: str, + label: str, + side: BodySide, + system_part: BodySystemPart, +) -> BodyArea: + area = BodyArea( + id=uuid4(), + code=code, + label=label, + side=side, + system_part=system_part, + ) + session.add(area) + await session.flush() + return area + + +async def _create_symptom(session: AsyncSession, *, name: str = "Dormência") -> Symptom: + symptom = Symptom( + id=uuid4(), + name=name, + category=SymptomCategory.neurological, + description="Test symptom", + ) + session.add(symptom) + await session.flush() + return symptom + + +@pytest.mark.usefixtures("create_tables") +async def test_body_areas_and_body_map_flow(async_client: AsyncClient, db_session: AsyncSession): + unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="bm-patient@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=unit) + + area_1 = await _create_body_area( + db_session, + code="left_forearm", + label="Antebraço esquerdo", + side=BodySide.left, + system_part=BodySystemPart.upper_limb, + ) + area_2 = await _create_body_area( + db_session, + code="right_cheek", + label="Bochecha direita", + side=BodySide.right, + system_part=BodySystemPart.head, + ) + headers = _auth_headers(patient_user.id, "patient") + + list_areas = await async_client.get("/v1/body-areas", headers=headers) + assert list_areas.status_code == 200 + assert len(list_areas.json()) >= 2 + + update = await async_client.put( + "/v1/body-map", + headers=headers, + json={ + "entries": [ + { + "body_area_id": str(area_1.id), + "finding_type": "lesion", + "intensity": 2, + "notes": "placa hipocrômica", + }, + { + "body_area_id": str(area_2.id), + "finding_type": "hypoesthesia", + "intensity": 1, + }, + ] + }, + ) + assert update.status_code == 200 + assert len(update.json()) == 2 + + current = await async_client.get("/v1/body-map", headers=headers) + assert current.status_code == 200 + payload = current.json() + assert len(payload) == 2 + assert payload[0]["body_area"]["id"] in {str(area_1.id), str(area_2.id)} + + soft_delete = await async_client.put( + "/v1/body-map", + headers=headers, + json={ + "entries": [ + { + "body_area_id": str(area_2.id), + "finding_type": "hypoesthesia", + "remove": True, + } + ] + }, + ) + assert soft_delete.status_code == 200 + assert len(soft_delete.json()) == 1 + assert soft_delete.json()[0]["body_area_id"] == str(area_1.id) + + invalid = await async_client.put( + "/v1/body-map", + headers=headers, + json={ + "entries": [ + { + "body_area_id": str(uuid4()), + "finding_type": "lesion", + } + ] + }, + ) + assert invalid.status_code == 404 + + +@pytest.mark.usefixtures("create_tables") +async def test_body_map_history_snapshot_and_upload( + async_client: AsyncClient, + db_session: AsyncSession, +): + unit = await _create_health_unit(db_session, name="UBS Centro") + patient_user = await _create_user(db_session, email="bm-hist@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=unit) + area = await _create_body_area( + db_session, + code="left_hand", + label="Mão esquerda", + side=BodySide.left, + system_part=BodySystemPart.upper_limb, + ) + symptom = await _create_symptom(db_session) + headers = _auth_headers(patient_user.id, "patient") + + await async_client.put( + "/v1/body-map", + headers=headers, + json={ + "entries": [ + { + "body_area_id": str(area.id), + "finding_type": "anesthesia", + "intensity": 3, + "image_key": "body-map/x/key.png", + "image_url": "https://cdn.example/body-map/x/key.png", + } + ] + }, + ) + + checkin_1 = await async_client.post( + "/v1/checkins", + headers=headers, + json={ + "mood": "ok", + "symptom_intensity": 3, + "symptom_ids": [str(symptom.id)], + "general_notes": "primeiro check-in", + }, + ) + assert checkin_1.status_code == 201 + + await async_client.put( + "/v1/body-map", + headers=headers, + json={ + "entries": [ + { + "body_area_id": str(area.id), + "finding_type": "lesion", + "intensity": 1, + } + ] + }, + ) + + checkin_2 = await async_client.post( + "/v1/checkins", + headers=headers, + json={ + "mood": "good", + "symptom_intensity": 4, + "symptom_ids": [str(symptom.id)], + "general_notes": "segundo check-in", + }, + ) + assert checkin_2.status_code == 409 # regra de um check-in por dia + + history = await async_client.get("/v1/body-map/history", headers=headers) + assert history.status_code == 200 + data = history.json() + assert len(data) == 1 + assert data[0]["finding_type"] == "anesthesia" + assert data[0]["image_key"] == "body-map/x/key.png" + + history_filter = await async_client.get( + f"/v1/body-map/history?body_area_id={area.id}&finding_type=anesthesia", + headers=headers, + ) + assert history_filter.status_code == 200 + assert len(history_filter.json()) == 1 + + upload = await async_client.post( + "/v1/body-map/upload", + headers=headers, + json={"filename": "lesao.png", "content_type": "image/png"}, + ) + assert upload.status_code == 200 + upload_data = upload.json() + assert "upload_url" in upload_data + assert upload_data["file_key"].startswith(f"body-map/{patient.id}/") + assert "signature=fake-signature" in upload_data["upload_url"] + + bad_upload = await async_client.post( + "/v1/body-map/upload", + headers=headers, + json={"filename": "lesao.txt", "content_type": "text/plain"}, + ) + assert bad_upload.status_code == 422 + + bad_extension = await async_client.post( + "/v1/body-map/upload", + headers=headers, + json={"filename": "malicious.exe", "content_type": "image/png"}, + ) + assert bad_extension.status_code == 422 + + +@pytest.mark.usefixtures("create_tables") +async def test_professional_history_access_is_tenant_scoped( + async_client: AsyncClient, + db_session: AsyncSession, +): + unit_a = await _create_health_unit(db_session, name="UBS A") + unit_b = await _create_health_unit(db_session, name="UBS B") + + patient_user = await _create_user(db_session, email="tenant-patient@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=unit_a) + prof_a_user = await _create_user( + db_session, + email="prof-a@test.com", + role="health_professional", + ) + prof_b_user = await _create_user( + db_session, + email="prof-b@test.com", + role="health_professional", + ) + await _create_professional(db_session, user=prof_a_user, health_unit=unit_a) + await _create_professional(db_session, user=prof_b_user, health_unit=unit_b) + + area = await _create_body_area( + db_session, + code="right_knee", + label="Joelho direito", + side=BodySide.right, + system_part=BodySystemPart.lower_limb, + ) + symptom = await _create_symptom(db_session, name="Dor neural") + + patient_headers = _auth_headers(patient_user.id, "patient") + await async_client.put( + "/v1/body-map", + headers=patient_headers, + json={ + "entries": [ + { + "body_area_id": str(area.id), + "finding_type": "nodule", + "intensity": 2, + } + ] + }, + ) + await async_client.post( + "/v1/checkins", + headers=patient_headers, + json={ + "mood": "ok", + "symptom_intensity": 2, + "symptom_ids": [str(symptom.id)], + "general_notes": "check-in com nódulo", + }, + ) + + same_tenant = await async_client.get( + f"/v1/body-map/history?patient_id={patient.id}", + headers=_auth_headers(prof_a_user.id, "health_professional"), + ) + assert same_tenant.status_code == 200 + assert len(same_tenant.json()) == 1 + + other_tenant = await async_client.get( + f"/v1/body-map/history?patient_id={patient.id}", + headers=_auth_headers(prof_b_user.id, "health_professional"), + ) + assert other_tenant.status_code == 403 + + missing_patient_id = await async_client.get( + "/v1/body-map/history", + headers=_auth_headers(prof_a_user.id, "health_professional"), + ) + assert missing_patient_id.status_code == 422 + + +@pytest.mark.usefixtures("create_tables") +async def test_history_date_range_filter(async_client: AsyncClient, db_session: AsyncSession): + unit = await _create_health_unit(db_session, name="UBS Date") + patient_user = await _create_user(db_session, email="date-patient@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=unit) + area = await _create_body_area( + db_session, + code="abdomen", + label="Abdômen", + side=BodySide.center, + system_part=BodySystemPart.trunk, + ) + symptom = await _create_symptom(db_session, name="Fadiga") + headers = _auth_headers(patient_user.id, "patient") + + await async_client.put( + "/v1/body-map", + headers=headers, + json={"entries": [{"body_area_id": str(area.id), "finding_type": "other", "intensity": 1}]}, + ) + await async_client.post( + "/v1/checkins", + headers=headers, + json={ + "mood": "good", + "symptom_intensity": 1, + "symptom_ids": [str(symptom.id)], + "general_notes": "snapshot", + }, + ) + + today = date.today() + filtered = await async_client.get( + "/v1/body-map/history", + headers=headers, + params={"from_date": today.isoformat(), "to_date": today.isoformat()}, + ) + assert filtered.status_code == 200 + assert len(filtered.json()) == 1 diff --git a/backend/tests/unit/test_body_map_schema.py b/backend/tests/unit/test_body_map_schema.py new file mode 100644 index 0000000..ff01249 --- /dev/null +++ b/backend/tests/unit/test_body_map_schema.py @@ -0,0 +1,52 @@ +import pytest +from pydantic import ValidationError + +from pequi.schemas.body_map import BodyMapUpdateRequest + + +def test_intensity_must_be_between_0_and_3(): + with pytest.raises(ValidationError): + BodyMapUpdateRequest.model_validate( + { + "entries": [ + { + "body_area_id": "5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26", + "finding_type": "lesion", + "intensity": 4, + } + ] + } + ) + + +def test_invalid_enum_is_rejected(): + with pytest.raises(ValidationError): + BodyMapUpdateRequest.model_validate( + { + "entries": [ + { + "body_area_id": "5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26", + "finding_type": "invalid", + "intensity": 2, + } + ] + } + ) + + +def test_valid_payload_is_accepted(): + payload = BodyMapUpdateRequest.model_validate( + { + "entries": [ + { + "body_area_id": "5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26", + "finding_type": "lesion", + "intensity": 3, + "notes": "Lesão com borda ativa", + } + ] + } + ) + + assert payload.entries[0].intensity == 3 + assert payload.entries[0].finding_type.value == "lesion" diff --git a/backend/tests/unit/test_body_map_use_cases.py b/backend/tests/unit/test_body_map_use_cases.py new file mode 100644 index 0000000..771e1f0 --- /dev/null +++ b/backend/tests/unit/test_body_map_use_cases.py @@ -0,0 +1,79 @@ +"""Testes unitários dos use cases de body map (regras de segurança e validação).""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ForbiddenError, ValidationFailedError +from pequi.use_cases.get_body_map_history import GetBodyMapHistoryUseCase +from pequi.use_cases.update_body_map import GenerateBodyMapUploadUrlUseCase + + +class _PatientRepoStub: + def __init__(self, patient: SimpleNamespace | None) -> None: + self._patient = patient + + async def get_by_user_id(self, user_id): # noqa: ARG002 + return self._patient + + async def get_by_id(self, patient_id): # noqa: ARG002 + return self._patient + + +class _ProfessionalRepoStub: + def __init__(self, professional: SimpleNamespace | None) -> None: + self._professional = professional + + async def get_by_user_id(self, user_id): # noqa: ARG002 + return self._professional + + +class _BodyMapRepoStub: + async def list_history_by_patient(self, *args, **kwargs): # noqa: ARG002 + return [] + + +class _StorageStub: + async def generate_body_map_upload_url(self, **kwargs): # noqa: ARG002 + raise AssertionError("storage should not be called when validation fails") + + +@pytest.mark.asyncio +async def test_professional_history_requires_patient_id() -> None: + use_case = GetBodyMapHistoryUseCase( + _BodyMapRepoStub(), + _PatientRepoStub(None), + _ProfessionalRepoStub(SimpleNamespace(id=uuid4(), health_unit_id=uuid4())), + ) + with pytest.raises(ValidationFailedError, match="patient_id"): + await use_case.execute(uuid4(), "health_professional", patient_id=None) + + +@pytest.mark.asyncio +async def test_professional_history_denies_when_health_unit_missing() -> None: + patient_id = uuid4() + patient = SimpleNamespace(id=patient_id, health_unit_id=None) + professional = SimpleNamespace(id=uuid4(), health_unit_id=None) + use_case = GetBodyMapHistoryUseCase( + _BodyMapRepoStub(), + _PatientRepoStub(patient), + _ProfessionalRepoStub(professional), + ) + with pytest.raises(ForbiddenError, match="unidade"): + await use_case.execute(uuid4(), "health_professional", patient_id=patient_id) + + +@pytest.mark.asyncio +async def test_upload_rejects_disallowed_extension() -> None: + patient = SimpleNamespace(id=uuid4()) + use_case = GenerateBodyMapUploadUrlUseCase( + _PatientRepoStub(patient), + _StorageStub(), + ) + with pytest.raises(ValidationFailedError, match="Extensão"): + await use_case.execute( + uuid4(), + filename="malicious.exe", + content_type="image/png", + ) From a3b530e384563c64ad17be51b84dabb201ce077d Mon Sep 17 00:00:00 2001 From: Rafael Luciano <74800037+rafaellucian0@users.noreply.github.com> Date: Wed, 27 May 2026 10:13:32 -0300 Subject: [PATCH 20/69] PEQ-81: Implement Anonymous Community (#27) * feat: implement anonymous community module with post, comment, like, and moderation features * feat: implement audit logging system and administrative moderation use cases for community management * fix: resolve NameError in community router by moving repository imports to module level and add documentation for code reviews * fix: ruff lint errors * fix: ruff format error * fix: migrations error * fix: tests with coverage errors * fix: coverage response * fix: undo test deleted and post response * fix: implement community module with database schema, models, and toggle-like use case * fix: raise exception error * fix: ruff variables errors Co-authored-by: Matheus Ryan --- .../alembic/versions/007_create_community.py | 196 +++++++++ .../alembic/versions/008_create_audit_logs.py | 48 +++ backend/bruno/admin-community/deanonymize.bru | 36 ++ .../bruno/admin-community/moderate_post.bru | 41 ++ backend/bruno/community/create_comment.bru | 45 ++ backend/bruno/community/create_post.bru | 48 +++ backend/bruno/community/delete_post.bru | 34 ++ backend/bruno/community/get_post.bru | 35 ++ backend/bruno/community/list_comments.bru | 35 ++ backend/bruno/community/list_posts.bru | 35 ++ backend/bruno/community/toggle_like.bru | 35 ++ backend/src/pequi/main.py | 7 + backend/src/pequi/models/__init__.py | 12 + backend/src/pequi/models/audit_log.py | 31 ++ backend/src/pequi/models/community.py | 155 +++++++ backend/src/pequi/repositories/audit_repo.py | 38 ++ .../src/pequi/repositories/community_repo.py | 324 ++++++++++++++ backend/src/pequi/routers/community.py | 188 ++++++++ backend/src/pequi/schemas/community.py | 87 ++++ backend/src/pequi/use_cases/create_comment.py | 29 ++ backend/src/pequi/use_cases/create_post.py | 25 ++ backend/src/pequi/use_cases/deanonymize.py | 44 ++ backend/src/pequi/use_cases/delete_post.py | 27 ++ backend/src/pequi/use_cases/get_post.py | 17 + backend/src/pequi/use_cases/list_comments.py | 32 ++ backend/src/pequi/use_cases/list_posts.py | 26 ++ backend/src/pequi/use_cases/moderate_post.py | 49 +++ backend/src/pequi/use_cases/toggle_like.py | 39 ++ backend/tests/conftest.py | 1 + .../tests/integration/test_community_flow.py | 408 ++++++++++++++++++ .../unit/test_community_anonymization.py | 103 +++++ 31 files changed, 2230 insertions(+) create mode 100644 backend/alembic/versions/007_create_community.py create mode 100644 backend/alembic/versions/008_create_audit_logs.py create mode 100644 backend/bruno/admin-community/deanonymize.bru create mode 100644 backend/bruno/admin-community/moderate_post.bru create mode 100644 backend/bruno/community/create_comment.bru create mode 100644 backend/bruno/community/create_post.bru create mode 100644 backend/bruno/community/delete_post.bru create mode 100644 backend/bruno/community/get_post.bru create mode 100644 backend/bruno/community/list_comments.bru create mode 100644 backend/bruno/community/list_posts.bru create mode 100644 backend/bruno/community/toggle_like.bru create mode 100644 backend/src/pequi/models/audit_log.py create mode 100644 backend/src/pequi/models/community.py create mode 100644 backend/src/pequi/repositories/audit_repo.py create mode 100644 backend/src/pequi/repositories/community_repo.py create mode 100644 backend/src/pequi/routers/community.py create mode 100644 backend/src/pequi/schemas/community.py create mode 100644 backend/src/pequi/use_cases/create_comment.py create mode 100644 backend/src/pequi/use_cases/create_post.py create mode 100644 backend/src/pequi/use_cases/deanonymize.py create mode 100644 backend/src/pequi/use_cases/delete_post.py create mode 100644 backend/src/pequi/use_cases/get_post.py create mode 100644 backend/src/pequi/use_cases/list_comments.py create mode 100644 backend/src/pequi/use_cases/list_posts.py create mode 100644 backend/src/pequi/use_cases/moderate_post.py create mode 100644 backend/src/pequi/use_cases/toggle_like.py create mode 100644 backend/tests/integration/test_community_flow.py create mode 100644 backend/tests/unit/test_community_anonymization.py diff --git a/backend/alembic/versions/007_create_community.py b/backend/alembic/versions/007_create_community.py new file mode 100644 index 0000000..2f13650 --- /dev/null +++ b/backend/alembic/versions/007_create_community.py @@ -0,0 +1,196 @@ +"""create community tables — M6 Community (PEQ-106) + +Revision ID: 007_create_community +Revises: 006_create_body_map +Create Date: 2026-05-26 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "007_create_community" +down_revision: str | None = "006_create_body_map" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + post_category_enum = postgresql.ENUM( + "experience", + "question", + "support", + "news", + name="post_category_enum", + ) + + post_category_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "community_anonymous_map", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("anonymous_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_anonymous_map_user_id", + ondelete="RESTRICT", + ), + sa.UniqueConstraint("user_id", name="uq_community_anonymous_map_user_id"), + sa.UniqueConstraint("anonymous_id", name="uq_community_anonymous_map_anonymous_id"), + ) + + op.create_index( + "ix_community_anonymous_map_user_id", + "community_anonymous_map", + ["user_id"], + ) + op.create_index( + "ix_community_anonymous_map_anonymous_id", + "community_anonymous_map", + ["anonymous_id"], + ) + + op.create_table( + "community_posts", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("author_anonymous_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column( + "category", + postgresql.ENUM( + "experience", + "question", + "support", + "news", + name="post_category_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("is_pinned", sa.Boolean(), server_default="false", nullable=False), + sa.Column("is_moderated", sa.Boolean(), server_default="false", nullable=False), + sa.Column("like_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("comment_count", sa.Integer(), server_default="0", nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["author_anonymous_id"], + ["community_anonymous_map.anonymous_id"], + name="fk_community_posts_author_anonymous_id_community_anonymous_map", + ondelete="RESTRICT", + ), + ) + + op.create_index( + "ix_community_posts_author_anonymous_id", + "community_posts", + ["author_anonymous_id"], + ) + + op.create_table( + "community_comments", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("post_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("author_anonymous_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["post_id"], + ["community_posts.id"], + name="fk_comments_post_id", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["author_anonymous_id"], + ["community_anonymous_map.anonymous_id"], + name="fk_comments_anonymous_id", + ondelete="RESTRICT", + ), + ) + + op.create_index( + "ix_community_comments_post_id", + "community_comments", + ["post_id"], + ) + op.create_index( + "ix_community_comments_author_anonymous_id", + "community_comments", + ["author_anonymous_id"], + ) + + op.create_table( + "community_likes", + sa.Column("anonymous_id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("post_id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["anonymous_id"], + ["community_anonymous_map.anonymous_id"], + name="fk_likes_anonymous_id", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["post_id"], + ["community_posts.id"], + name="fk_likes_post_id", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("anonymous_id", "post_id"), + ) + + +def downgrade() -> None: + op.drop_table("community_likes") + + op.drop_index("ix_community_comments_anonymous_id", table_name="community_comments") + op.drop_index("ix_community_comments_post_id", table_name="community_comments") + op.drop_table("community_comments") + + op.drop_index("ix_community_posts_anonymous_id", table_name="community_posts") + op.drop_table("community_posts") + + op.drop_index("ix_community_anonymous_map_anonymous_id", table_name="community_anonymous_map") + op.drop_index("ix_community_anonymous_map_user_id", table_name="community_anonymous_map") + op.drop_table("community_anonymous_map") + + sa.Enum(name="post_category_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/008_create_audit_logs.py b/backend/alembic/versions/008_create_audit_logs.py new file mode 100644 index 0000000..1f71125 --- /dev/null +++ b/backend/alembic/versions/008_create_audit_logs.py @@ -0,0 +1,48 @@ +"""create audit_logs table — LGPD compliance and admin actions audit + +Revision ID: 008_create_audit_logs +Revises: 007_create_community +Create Date: 2026-05-26 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "008_create_audit_logs" +down_revision: str | None = "007_create_community" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "audit_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("actor_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("actor_role", sa.String(50), nullable=False), + sa.Column("entity_type", sa.String(100), nullable=False), + sa.Column("entity_id", sa.String(255), nullable=True), + sa.Column("action", sa.String(100), nullable=False), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + + op.create_index("ix_audit_logs_actor_user_id", "audit_logs", ["actor_user_id"]) + op.create_index("ix_audit_logs_entity_type", "audit_logs", ["entity_type"]) + op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_logs_created_at", table_name="audit_logs") + op.drop_index("ix_audit_logs_entity_type", table_name="audit_logs") + op.drop_index("ix_audit_logs_actor_user_id", table_name="audit_logs") + op.drop_table("audit_logs") diff --git a/backend/bruno/admin-community/deanonymize.bru b/backend/bruno/admin-community/deanonymize.bru new file mode 100644 index 0000000..3d56852 --- /dev/null +++ b/backend/bruno/admin-community/deanonymize.bru @@ -0,0 +1,36 @@ +meta { + name: Deanonymize + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/admin/community/deanonymize/{{anonymousId}} + auth: bearer +} + +auth:bearer { + token: {{adminToken}} +} + +headers { + Content-Type: application/json +} + +assert { + res.status: eq 200 + res.body.anonymous_id: isDefined + res.body.user_id: isDefined + res.body.created_at: isDefined +} + +docs { + Deanonymiza anonymous_id (admin only) - PEQ-106. + + - Apenas admin pode acessar. + - Expõe user_id real correspondente ao anonymous_id. + - Ação CRITICAMENTE auditada em logs (warning level). + - Use apenas quando necessário (investigações, denúncias). + + Rate limit: 20/hora. +} diff --git a/backend/bruno/admin-community/moderate_post.bru b/backend/bruno/admin-community/moderate_post.bru new file mode 100644 index 0000000..babf671 --- /dev/null +++ b/backend/bruno/admin-community/moderate_post.bru @@ -0,0 +1,41 @@ +meta { + name: Moderate Post + type: http + seq: 1 +} + +patch { + url: {{baseUrl}}/v1/admin/community/posts/{{postId}}/moderate + body: json + auth: bearer +} + +auth:bearer { + token: {{adminToken}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "is_moderated": true + } +} + +assert { + res.status: eq 200 + res.body.is_moderated: eq true +} + +docs { + Modera post (admin only) - PEQ-106. + + - Apenas admin pode acessar. + - Marca post como moderado/removido. + - Ação auditada em logs. + - Posts moderados não aparecem em listagens públicas. + + Rate limit: 20/hora. +} diff --git a/backend/bruno/community/create_comment.bru b/backend/bruno/community/create_comment.bru new file mode 100644 index 0000000..c3cc4cb --- /dev/null +++ b/backend/bruno/community/create_comment.bru @@ -0,0 +1,45 @@ +meta { + name: Create Comment + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/v1/community/posts/{{postId}}/comments + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "content": "Ótimo post! Obrigado por compartilhar." + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.post_id: isDefined + res.body.author_anonymous_id: isDefined + res.body.content: isDefined + res.body.user_id: isNotDefined +} + +docs { + Cria comentário anônimo em um post (PEQ-106). + + - Apenas pacientes podem comentar. + - Sistema usa anonymous_id do usuário. + - Nunca expõe user_id na resposta. + - Incrementa comment_count do post. + + Rate limit: 30/hora. +} diff --git a/backend/bruno/community/create_post.bru b/backend/bruno/community/create_post.bru new file mode 100644 index 0000000..4da829b --- /dev/null +++ b/backend/bruno/community/create_post.bru @@ -0,0 +1,48 @@ +meta { + name: Create Post + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/v1/community/posts + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "title": "Minha experiência com o tratamento", + "content": "Estou compartilhando minha jornada de tratamento...", + "category": "experience" + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.author_anonymous_id: isDefined + res.body.title: isDefined + res.body.content: isDefined + res.body.category: isDefined + res.body.user_id: isNotDefined +} + +docs { + Cria post anônimo na comunidade (PEQ-106). + + - Apenas pacientes podem criar posts. + - category: experience | question | support | news + - Sistema gera anonymous_id automaticamente. + - Nunca expõe user_id na resposta. + + Rate limit: 20/hora. +} diff --git a/backend/bruno/community/delete_post.bru b/backend/bruno/community/delete_post.bru new file mode 100644 index 0000000..fdf9a59 --- /dev/null +++ b/backend/bruno/community/delete_post.bru @@ -0,0 +1,34 @@ +meta { + name: Delete Post + type: http + seq: 6 +} + +delete { + url: {{baseUrl}}/v1/community/posts/{{postId}} + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +assert { + res.status: eq 200 + res.body.deleted_at: isDefined +} + +docs { + Soft delete de post (PEQ-106). + + - Próprio autor ou admin pode deletar. + - Soft delete: define deleted_at (não remove do banco). + - Post não aparece mais em listagens. + - 403 se usuário não é dono do post. + + Rate limit: 10/hora. +} diff --git a/backend/bruno/community/get_post.bru b/backend/bruno/community/get_post.bru new file mode 100644 index 0000000..31c3526 --- /dev/null +++ b/backend/bruno/community/get_post.bru @@ -0,0 +1,35 @@ +meta { + name: Get Post + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v1/community/posts/{{postId}} + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +assert { + res.status: eq 200 + res.body.id: isDefined + res.body.author_anonymous_id: isDefined + res.body.user_id: isNotDefined +} + +docs { + Retorna um post específico por ID (PEQ-106). + + - Qualquer usuário autenticado pode acessar. + - Retorna apenas author_anonymous_id (nunca user_id). + - 404 se post não existe ou foi deletado. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/community/list_comments.bru b/backend/bruno/community/list_comments.bru new file mode 100644 index 0000000..8effbe6 --- /dev/null +++ b/backend/bruno/community/list_comments.bru @@ -0,0 +1,35 @@ +meta { + name: List Comments + type: http + seq: 7 +} + +get { + url: {{baseUrl}}/v1/community/posts/{{postId}}/comments + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +assert { + res.status: eq 200 + res.body.items: isDefined + res.body.total: isDefined +} + +docs { + Lista comentários de um post (PEQ-106). + + - Qualquer usuário autenticado pode acessar. + - Retorna apenas author_anonymous_id (nunca user_id). + - Paginado com limit e offset. + - Ordenado por created_at asc. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/community/list_posts.bru b/backend/bruno/community/list_posts.bru new file mode 100644 index 0000000..ecb64b5 --- /dev/null +++ b/backend/bruno/community/list_posts.bru @@ -0,0 +1,35 @@ +meta { + name: List Posts + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/v1/community/posts + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +assert { + res.status: eq 200 + res.body.items: isDefined + res.body.total: isDefined +} + +docs { + Lista posts da comunidade anônima (PEQ-106). + + - Qualquer usuário autenticado pode acessar. + - Retorna apenas author_anonymous_id (nunca user_id). + - Exclui posts moderados por padrão. + - Paginado com limit e offset. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/community/toggle_like.bru b/backend/bruno/community/toggle_like.bru new file mode 100644 index 0000000..293a8cd --- /dev/null +++ b/backend/bruno/community/toggle_like.bru @@ -0,0 +1,35 @@ +meta { + name: Toggle Like + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/v1/community/posts/{{postId}}/like + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +assert { + res.status: eq 200 + res.body.liked: isDefined + res.body.like_count: isDefined +} + +docs { + Toggle like em post (PEQ-106). + + - Apenas pacientes podem dar like. + - Primeira chamada: cria like (liked=true). + - Segunda chamada: remove like (liked=false). + - Retorna contador atualizado de likes. + + Rate limit: 60/hora. +} diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index efa91bb..2a91474 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -69,6 +69,7 @@ async def health_check() -> JSONResponse: from pequi.routers import auth as auth_router from pequi.routers import body_map as body_map_router from pequi.routers import checkin as checkin_router + from pequi.routers import community as community_router from pequi.routers import patient as patient_router from pequi.routers import treatment as treatment_router @@ -80,6 +81,12 @@ async def health_check() -> JSONResponse: app.include_router(checkin_router.alerts_router, prefix="/v1/alerts", tags=["alerts"]) app.include_router(body_map_router.router, prefix="/v1/body-map", tags=["body-map"]) app.include_router(body_map_router.areas_router, prefix="/v1/body-areas", tags=["body-map"]) + app.include_router(community_router.router, prefix="/v1/community", tags=["community"]) + app.include_router( + community_router.admin_router, + prefix="/v1/admin/community", + tags=["admin-community"], + ) app = create_app() diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 08316fa..fe87192 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -1,6 +1,13 @@ from pequi.models.alert import Alert +from pequi.models.audit_log import AuditLog from pequi.models.body_map import BodyArea, BodyAreaHistory, BodyMapEntry from pequi.models.checkin import Checkin +from pequi.models.community import ( + CommunityAnonymousMap, + CommunityComment, + CommunityLike, + CommunityPost, +) from pequi.models.consent import Consent from pequi.models.dose_log import AdherenceSnapshot, DoseLog from pequi.models.health_professional import HealthProfessional @@ -13,10 +20,15 @@ __all__ = [ "AdherenceSnapshot", "Alert", + "AuditLog", "BodyArea", "BodyAreaHistory", "BodyMapEntry", "Checkin", + "CommunityAnonymousMap", + "CommunityComment", + "CommunityLike", + "CommunityPost", "Consent", "DoseLog", "DoseSchedule", diff --git a/backend/src/pequi/models/audit_log.py b/backend/src/pequi/models/audit_log.py new file mode 100644 index 0000000..3447a5c --- /dev/null +++ b/backend/src/pequi/models/audit_log.py @@ -0,0 +1,31 @@ +import uuid + +from sqlalchemy import Column, DateTime, String, Text, func +from sqlalchemy.dialects.postgresql import UUID + +from pequi.database import Base + + +class AuditLog(Base): + """Tabela de auditoria para ações sensíveis — append-only, sem DELETE ou UPDATE. + + Registra consultas a dados clínicos (patient_profile) e ações de admin + em community_anonymous_map (deanonymization). + """ + + __tablename__ = "audit_logs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + actor_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + actor_role = Column(String(50), nullable=False) + entity_type = Column(String(100), nullable=False, index=True) + entity_id = Column(String(255), nullable=True) + action = Column(String(100), nullable=False) + details = Column(Text, nullable=True) + ip_address = Column(String(45), nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + index=True, + ) diff --git a/backend/src/pequi/models/community.py b/backend/src/pequi/models/community.py new file mode 100644 index 0000000..891f04b --- /dev/null +++ b/backend/src/pequi/models/community.py @@ -0,0 +1,155 @@ +import uuid + +from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from pequi.database import Base + + +class CommunityAnonymousMap(Base): + """Mapeamento entre user_id real e anonymous_id para posts anônimos. + + Acesso restrito a role admin. Nunca exposto via API pública. + """ + + __tablename__ = "community_anonymous_map" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + unique=True, + nullable=False, + index=True, + ) + anonymous_id = Column( + UUID(as_uuid=True), + unique=True, + nullable=False, + index=True, + default=uuid.uuid4, + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + posts = relationship( + "CommunityPost", + back_populates="author_mapping", + ) + comments = relationship( + "CommunityComment", + back_populates="author_mapping", + ) + likes = relationship( + "CommunityLike", + back_populates="author_mapping", + ) + + +class CommunityPost(Base): + """Posts da comunidade anônima.""" + + __tablename__ = "community_posts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + author_anonymous_id = Column( + UUID(as_uuid=True), + ForeignKey("community_anonymous_map.anonymous_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + title = Column(Text, nullable=False) + content = Column(Text, nullable=False) + category = Column( + Enum("experience", "question", "support", "news", name="post_category_enum"), + nullable=False, + ) + is_pinned = Column(Boolean, server_default="false", nullable=False) + is_moderated = Column(Boolean, server_default="false", nullable=False) + like_count = Column(Integer, server_default="0", nullable=False) + comment_count = Column(Integer, server_default="0", nullable=False) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + nullable=False, + ) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + author_mapping = relationship("CommunityAnonymousMap", back_populates="posts") + comments = relationship("CommunityComment", back_populates="post", cascade="all, delete-orphan") + likes = relationship("CommunityLike", back_populates="post", cascade="all, delete-orphan") + + +class CommunityComment(Base): + """Comentários em posts da comunidade.""" + + __tablename__ = "community_comments" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + post_id = Column( + UUID(as_uuid=True), + ForeignKey("community_posts.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + author_anonymous_id = Column( + UUID(as_uuid=True), + ForeignKey("community_anonymous_map.anonymous_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + content = Column(Text, nullable=False) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + nullable=False, + ) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + post = relationship("CommunityPost", back_populates="comments") + author_mapping = relationship("CommunityAnonymousMap", back_populates="comments") + + +class CommunityLike(Base): + """Likes em posts da comunidade.""" + + __tablename__ = "community_likes" + + anonymous_id = Column( + UUID(as_uuid=True), + ForeignKey("community_anonymous_map.anonymous_id", ondelete="CASCADE"), + primary_key=True, + ) + post_id = Column( + UUID(as_uuid=True), + ForeignKey("community_posts.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + author_mapping = relationship("CommunityAnonymousMap", back_populates="likes") + post = relationship("CommunityPost", back_populates="likes") diff --git a/backend/src/pequi/repositories/audit_repo.py b/backend/src/pequi/repositories/audit_repo.py new file mode 100644 index 0000000..ac9d52f --- /dev/null +++ b/backend/src/pequi/repositories/audit_repo.py @@ -0,0 +1,38 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.audit_log import AuditLog + + +class AuditRepository: + """Repository para persistir logs de auditoria em tabela (LGPD compliance).""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def log_action( + self, + *, + actor_user_id: UUID, + actor_role: str, + entity_type: str, + entity_id: str | None = None, + action: str, + details: str | None = None, + ip_address: str | None = None, + ) -> AuditLog: + """Persiste ação de auditoria na tabela audit_logs (append-only).""" + audit_log = AuditLog( + actor_user_id=actor_user_id, + actor_role=actor_role, + entity_type=entity_type, + entity_id=entity_id, + action=action, + details=details, + ip_address=ip_address, + ) + self._session.add(audit_log) + await self._session.flush() + await self._session.refresh(audit_log) + return audit_log diff --git a/backend/src/pequi/repositories/community_repo.py b/backend/src/pequi/repositories/community_repo.py new file mode 100644 index 0000000..2a6073d --- /dev/null +++ b/backend/src/pequi/repositories/community_repo.py @@ -0,0 +1,324 @@ +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import and_, delete, func, insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.community import ( + CommunityAnonymousMap, + CommunityComment, + CommunityLike, + CommunityPost, +) +from pequi.schemas.community import CommentCreate, PostCreate + + +class CommunityRepository: + """Repository para operações da comunidade com lógica de anonimização. + + Nunca expõe user_id via API — apenas anonymous_id. + O mapeamento real fica em community_anonymous_map com acesso restrito. + """ + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_anonymous_id(self, user_id: UUID) -> UUID | None: + """Retorna anonymous_id existente ou None (read-only).""" + stmt = select(CommunityAnonymousMap.anonymous_id).where( + CommunityAnonymousMap.user_id == user_id + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_or_create_anonymous_id(self, user_id: UUID) -> UUID: + """Retorna anonymous_id existente ou cria novo mapeamento (race-safe). + + O anonymous_id é estável por usuário — o mesmo em todos os posts. + """ + from sqlalchemy.exc import IntegrityError + + stmt = select(CommunityAnonymousMap.anonymous_id).where( + CommunityAnonymousMap.user_id == user_id + ) + result = await self._session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + return existing + + # Criar novo mapeamento (tratar race condition) + try: + mapping = CommunityAnonymousMap(user_id=user_id) + self._session.add(mapping) + await self._session.flush() + await self._session.refresh(mapping) + return mapping.anonymous_id + except IntegrityError: + # Outra requisição criou o mapeamento, buscar novamente + result = await self._session.execute(stmt) + existing = result.scalar_one_or_none() + if existing: + return existing + raise + + async def deanonymize(self, anonymous_id: UUID) -> CommunityAnonymousMap | None: + """Retorna o mapeamento completo (user_id real) — acesso restrito a admin. + + Esta operação deve ser auditada quando chamada via API. + """ + stmt = select(CommunityAnonymousMap).where( + CommunityAnonymousMap.anonymous_id == anonymous_id + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def create_post( + self, + user_id: UUID, + data: PostCreate, + ) -> CommunityPost: + """Cria post anônimo — usa anonymous_id, nunca expõe user_id.""" + anonymous_id = await self.get_or_create_anonymous_id(user_id) + + post = CommunityPost( + author_anonymous_id=anonymous_id, + title=data.title, + content=data.content, + category=data.category, + ) + self._session.add(post) + await self._session.flush() + await self._session.refresh(post) + return post + + async def get_post_by_id(self, post_id: UUID) -> CommunityPost | None: + """Retorna post por ID — retorna apenas anonymous_id.""" + stmt = ( + select(CommunityPost) + .where(CommunityPost.id == post_id) + .where(CommunityPost.deleted_at.is_(None)) + .where(CommunityPost.is_moderated.is_(False)) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_posts( + self, + *, + limit: int = 50, + offset: int = 0, + category: str | None = None, + exclude_moderated: bool = True, + ) -> tuple[list[CommunityPost], int]: + """Lista posts paginados — nunca expõe user_id.""" + filters = [CommunityPost.deleted_at.is_(None)] + if exclude_moderated: + filters.append(CommunityPost.is_moderated.is_(False)) + if category: + filters.append(CommunityPost.category == category) + + count_stmt = select(func.count()).select_from(CommunityPost).where(*filters) + total = (await self._session.execute(count_stmt)).scalar_one() + + stmt = ( + select(CommunityPost) + .where(*filters) + .order_by(CommunityPost.is_pinned.desc(), CommunityPost.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()), total + + async def update_post_moderation( + self, + post_id: UUID, + is_moderated: bool, + ) -> CommunityPost | None: + """Atualiza status de moderação (admin only).""" + stmt = ( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(is_moderated=is_moderated) + .returning(CommunityPost) + ) + result = await self._session.execute(stmt) + await self._session.flush() + return result.scalar_one_or_none() + + async def soft_delete_post(self, post_id: UUID) -> CommunityPost | None: + """Soft delete de post (próprio autor ou admin).""" + stmt = ( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(deleted_at=datetime.now(UTC)) + .returning(CommunityPost) + ) + result = await self._session.execute(stmt) + await self._session.flush() + return result.scalar_one_or_none() + + async def check_post_ownership( + self, + post_id: UUID, + user_id: UUID, + ) -> bool: + """Verifica se o usuário é dono do post via anonymous_id.""" + anonymous_id = await self.get_anonymous_id(user_id) + if anonymous_id is None: + return False + stmt = select(CommunityPost.id).where( + and_( + CommunityPost.id == post_id, + CommunityPost.author_anonymous_id == anonymous_id, + CommunityPost.deleted_at.is_(None), + ) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() is not None + + async def create_comment( + self, + user_id: UUID, + post_id: UUID, + data: CommentCreate, + ) -> CommunityComment: + """Cria comentário anônimo — usa anonymous_id, nunca expõe user_id.""" + anonymous_id = await self.get_or_create_anonymous_id(user_id) + + comment = CommunityComment( + post_id=post_id, + author_anonymous_id=anonymous_id, + content=data.content, + ) + self._session.add(comment) + await self._session.flush() + + # Incrementar contador de comentários no post + await self._session.execute( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(comment_count=CommunityPost.comment_count + 1) + ) + await self._session.flush() + + await self._session.refresh(comment) + return comment + + async def list_comments( + self, + post_id: UUID, + *, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[CommunityComment], int]: + """Lista comentários de um post — nunca expõe user_id.""" + filters = [ + CommunityComment.post_id == post_id, + CommunityComment.deleted_at.is_(None), + ] + + count_stmt = select(func.count()).select_from(CommunityComment).where(*filters) + total = (await self._session.execute(count_stmt)).scalar_one() + + stmt = ( + select(CommunityComment) + .where(*filters) + .order_by(CommunityComment.created_at.asc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()), total + + async def add_like( + self, + user_id: UUID, + post_id: UUID, + ) -> tuple[bool, int]: + """Adiciona like em post — retorna (liked, like_count). + + Atômico: incrementa like_count apenas se insert for bem-sucedido. + Levanta IntegrityError se like já existe. + """ + anonymous_id = await self.get_or_create_anonymous_id(user_id) + + # Adicionar like + await self._session.execute( + insert(CommunityLike).values( + anonymous_id=anonymous_id, + post_id=post_id, + ) + ) + await self._session.execute( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(like_count=CommunityPost.like_count + 1) + ) + await self._session.flush() + return True, await self._get_post_like_count(post_id) + + async def remove_like( + self, + user_id: UUID, + post_id: UUID, + ) -> tuple[bool, int]: + """Remove like em post — retorna (liked, like_count).""" + anonymous_id = await self.get_or_create_anonymous_id(user_id) + + # Remover like + await self._session.execute( + delete(CommunityLike).where( + and_( + CommunityLike.anonymous_id == anonymous_id, + CommunityLike.post_id == post_id, + ) + ) + ) + await self._session.execute( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(like_count=CommunityPost.like_count - 1) + ) + await self._session.flush() + return False, await self._get_post_like_count(post_id) + + async def check_like_exists( + self, + user_id: UUID, + post_id: UUID, + ) -> bool: + """Verifica se o usuário já curtiu o post.""" + anonymous_id = await self.get_or_create_anonymous_id(user_id) + stmt = select(CommunityLike).where( + and_( + CommunityLike.anonymous_id == anonymous_id, + CommunityLike.post_id == post_id, + ) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() is not None + + async def _get_post_like_count(self, post_id: UUID) -> int: + """Retorna contador atual de likes de um post.""" + stmt = select(CommunityPost.like_count).where(CommunityPost.id == post_id) + result = await self._session.execute(stmt) + return result.scalar_one() or 0 + + async def check_comment_ownership( + self, + comment_id: UUID, + user_id: UUID, + ) -> bool: + """Verifica se o usuário é dono do comentário via anonymous_id.""" + anonymous_id = await self.get_or_create_anonymous_id(user_id) + stmt = select(CommunityComment.id).where( + and_( + CommunityComment.id == comment_id, + CommunityComment.author_anonymous_id == anonymous_id, + CommunityComment.deleted_at.is_(None), + ) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() is not None diff --git a/backend/src/pequi/routers/community.py b/backend/src/pequi/routers/community.py new file mode 100644 index 0000000..819f6c3 --- /dev/null +++ b/backend/src/pequi/routers/community.py @@ -0,0 +1,188 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import ( + get_actor_from_token, + get_current_admin, + get_current_patient, + get_db, +) +from pequi.core.rate_limit import user_limiter +from pequi.repositories.audit_repo import AuditRepository +from pequi.repositories.community_repo import CommunityRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.community import ( + CommentCreate, + CommentListResponse, + CommentResponse, + DeanonymizeResponse, + PostCreate, + PostListResponse, + PostModerate, + PostResponse, +) +from pequi.use_cases.create_comment import CreateCommentUseCase +from pequi.use_cases.create_post import CreatePostUseCase +from pequi.use_cases.deanonymize import DeanonymizeUseCase +from pequi.use_cases.delete_post import DeletePostUseCase +from pequi.use_cases.get_post import GetPostUseCase +from pequi.use_cases.list_comments import ListCommentsUseCase +from pequi.use_cases.list_posts import ListPostsUseCase +from pequi.use_cases.moderate_post import ModeratePostUseCase +from pequi.use_cases.toggle_like import ToggleLikeUseCase + +router = APIRouter() +admin_router = APIRouter() + + +def _community_repos( + session: AsyncSession, +) -> tuple[CommunityRepository, PatientRepository]: + return ( + CommunityRepository(session), + PatientRepository(session), + ) + + +def _admin_community_repos( + session: AsyncSession, +) -> tuple[CommunityRepository, AuditRepository]: + return ( + CommunityRepository(session), + AuditRepository(session), + ) + + +@router.get("/posts", response_model=PostListResponse) +@user_limiter.limit("100/minute") +async def list_posts( + request: Request, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + category: str | None = Query(default=None), +) -> PostListResponse: + """Lista posts da comunidade — qualquer usuário autenticado pode acessar.""" + community_repo, _ = _community_repos(session) + use_case = ListPostsUseCase(community_repo) + return await use_case.execute(limit=limit, offset=offset, category=category) + + +@router.post("/posts", response_model=PostResponse, status_code=201) +@user_limiter.limit("20/hour") +async def create_post( + request: Request, + body: PostCreate, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> PostResponse: + """Cria post anônimo na comunidade — apenas pacientes.""" + community_repo, patient_repo = _community_repos(session) + use_case = CreatePostUseCase(community_repo, patient_repo) + return await use_case.execute(user_id, body) + + +@router.get("/posts/{post_id}", response_model=PostResponse) +@user_limiter.limit("100/minute") +async def get_post( + request: Request, + post_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> PostResponse: + """Retorna um post específico — qualquer usuário autenticado pode acessar.""" + community_repo, _ = _community_repos(session) + use_case = GetPostUseCase(community_repo) + return await use_case.execute(post_id) + + +@router.delete("/posts/{post_id}", response_model=PostResponse) +@user_limiter.limit("10/hour") +async def delete_post( + request: Request, + post_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> PostResponse: + """Soft delete de post — próprio autor ou admin.""" + user_id, role = actor + community_repo, _ = _community_repos(session) + use_case = DeletePostUseCase(community_repo) + return await use_case.execute(user_id, post_id, is_admin=(role == "admin")) + + +@router.post("/posts/{post_id}/comments", response_model=CommentResponse, status_code=201) +@user_limiter.limit("30/hour") +async def create_comment( + request: Request, + post_id: UUID, + body: CommentCreate, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> CommentResponse: + """Cria comentário anônimo em um post — apenas pacientes.""" + community_repo, patient_repo = _community_repos(session) + use_case = CreateCommentUseCase(community_repo, patient_repo) + return await use_case.execute(user_id, post_id, body) + + +@router.get("/posts/{post_id}/comments", response_model=CommentListResponse) +@user_limiter.limit("100/minute") +async def list_comments( + request: Request, + post_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> CommentListResponse: + """Lista comentários de um post — qualquer usuário autenticado pode acessar.""" + community_repo, _ = _community_repos(session) + use_case = ListCommentsUseCase(community_repo) + return await use_case.execute(post_id, limit=limit, offset=offset) + + +@router.post("/posts/{post_id}/like") +@user_limiter.limit("60/hour") +async def toggle_like( + request: Request, + post_id: UUID, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> dict: + """Toggle like em post — apenas pacientes.""" + community_repo, patient_repo = _community_repos(session) + use_case = ToggleLikeUseCase(community_repo, patient_repo) + return await use_case.execute(user_id, post_id) + + +@admin_router.patch("/posts/{post_id}/moderate", response_model=PostResponse) +@user_limiter.limit("20/hour") +async def moderate_post( + request: Request, + post_id: UUID, + body: PostModerate, + admin_user_id: UUID = Depends(get_current_admin), + session: AsyncSession = Depends(get_db), +) -> PostResponse: + """Modera post (admin only) — marca como moderado/removido.""" + community_repo, audit_repo = _admin_community_repos(session) + use_case = ModeratePostUseCase(community_repo, audit_repo) + return await use_case.execute(post_id, body, admin_user_id) + + +@admin_router.get("/deanonymize/{anonymous_id}", response_model=DeanonymizeResponse) +@user_limiter.limit("20/hour") +async def deanonymize( + request: Request, + anonymous_id: UUID, + admin_user_id: UUID = Depends(get_current_admin), + session: AsyncSession = Depends(get_db), +) -> DeanonymizeResponse: + """Deanonymiza anonymous_id (admin only) — expõe user_id real com auditoria.""" + community_repo, audit_repo = _admin_community_repos(session) + use_case = DeanonymizeUseCase(community_repo, audit_repo) + return await use_case.execute(anonymous_id, admin_user_id) diff --git a/backend/src/pequi/schemas/community.py b/backend/src/pequi/schemas/community.py new file mode 100644 index 0000000..616ca63 --- /dev/null +++ b/backend/src/pequi/schemas/community.py @@ -0,0 +1,87 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class PostCreate(BaseModel): + """Payload para criação de post na comunidade anônima (PEQ-106).""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field(..., min_length=3, max_length=200, description="Título do post") + content: str = Field(..., min_length=10, max_length=5000, description="Conteúdo do post") + category: str = Field( + ..., + pattern="^(experience|question|support|news)$", + description="Categoria do post", + ) + + +class CommentCreate(BaseModel): + """Payload para criação de comentário em post (PEQ-106).""" + + model_config = ConfigDict(extra="forbid") + + content: str = Field(..., min_length=3, max_length=2000, description="Conteúdo do comentário") + + +class PostResponse(BaseModel): + """Resposta de post da comunidade — nunca expõe user_id, apenas anonymous_id.""" + + id: UUID + author_anonymous_id: UUID + title: str + content: str + category: str + is_pinned: bool + is_moderated: bool + like_count: int + comment_count: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CommentResponse(BaseModel): + """Resposta de comentário — nunca expõe user_id, apenas anonymous_id.""" + + id: UUID + post_id: UUID + author_anonymous_id: UUID + content: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class PostListResponse(BaseModel): + """Lista paginada de posts da comunidade.""" + + items: list[PostResponse] + total: int + + +class CommentListResponse(BaseModel): + """Lista de comentários de um post.""" + + items: list[CommentResponse] + total: int + + +class PostModerate(BaseModel): + """Payload para moderação de post (admin only).""" + + is_moderated: bool = Field(..., description="Marca o post como moderado/removido") + + +class DeanonymizeResponse(BaseModel): + """Resposta de deanonymização (admin only) — expõe user_id real com auditoria.""" + + anonymous_id: UUID + user_id: UUID + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/use_cases/create_comment.py b/backend/src/pequi/use_cases/create_comment.py new file mode 100644 index 0000000..3c57b90 --- /dev/null +++ b/backend/src/pequi/use_cases/create_comment.py @@ -0,0 +1,29 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.community import CommentCreate, CommentResponse + + +class CreateCommentUseCase: + def __init__( + self, + community_repo: CommunityRepository, + patient_repo: PatientRepository, + ) -> None: + self._community_repo = community_repo + self._patient_repo = patient_repo + + async def execute(self, user_id: UUID, post_id: UUID, data: CommentCreate) -> CommentResponse: + """Cria comentário anônimo em um post.""" + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + post = await self._community_repo.get_post_by_id(post_id) + if post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + comment = await self._community_repo.create_comment(user_id, post_id, data) + return CommentResponse.model_validate(comment) diff --git a/backend/src/pequi/use_cases/create_post.py b/backend/src/pequi/use_cases/create_post.py new file mode 100644 index 0000000..1a60d8b --- /dev/null +++ b/backend/src/pequi/use_cases/create_post.py @@ -0,0 +1,25 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.community import PostCreate, PostResponse + + +class CreatePostUseCase: + def __init__( + self, + community_repo: CommunityRepository, + patient_repo: PatientRepository, + ) -> None: + self._community_repo = community_repo + self._patient_repo = patient_repo + + async def execute(self, user_id: UUID, data: PostCreate) -> PostResponse: + """Cria post anônimo na comunidade.""" + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + post = await self._community_repo.create_post(user_id, data) + return PostResponse.model_validate(post) diff --git a/backend/src/pequi/use_cases/deanonymize.py b/backend/src/pequi/use_cases/deanonymize.py new file mode 100644 index 0000000..3841319 --- /dev/null +++ b/backend/src/pequi/use_cases/deanonymize.py @@ -0,0 +1,44 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.core.logging import get_logger +from pequi.repositories.audit_repo import AuditRepository +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import DeanonymizeResponse + +logger = get_logger(__name__) + + +class DeanonymizeUseCase: + def __init__( + self, + community_repo: CommunityRepository, + audit_repo: AuditRepository, + ) -> None: + self._community_repo = community_repo + self._audit_repo = audit_repo + + async def execute(self, anonymous_id: UUID, admin_user_id: UUID) -> DeanonymizeResponse: + """Deanonymiza anonymous_id (admin only) — ação auditada em tabela audit_logs.""" + mapping = await self._community_repo.deanonymize(anonymous_id) + if mapping is None: + raise NotFoundError("CommunityAnonymousMap", str(anonymous_id)) + + # Persistir auditoria em tabela (LGPD compliance - append-only) + await self._audit_repo.log_action( + actor_user_id=admin_user_id, + actor_role="admin", + entity_type="community_anonymous_map", + entity_id=str(anonymous_id), + action="deanonymize", + details="Deanonymized anonymous_id to real user", + ) + + # Log estruturado adicional para observabilidade + logger.warning( + "community.deanonymize", + admin_user_id=str(admin_user_id), + anonymous_id=str(anonymous_id), + ) + + return DeanonymizeResponse.model_validate(mapping) diff --git a/backend/src/pequi/use_cases/delete_post.py b/backend/src/pequi/use_cases/delete_post.py new file mode 100644 index 0000000..ca3cea4 --- /dev/null +++ b/backend/src/pequi/use_cases/delete_post.py @@ -0,0 +1,27 @@ +from uuid import UUID + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import PostResponse + + +class DeletePostUseCase: + def __init__(self, community_repo: CommunityRepository) -> None: + self._community_repo = community_repo + + async def execute(self, user_id: UUID, post_id: UUID, is_admin: bool = False) -> PostResponse: + """Soft delete de post (próprio autor ou admin).""" + post = await self._community_repo.get_post_by_id(post_id) + if post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + if not is_admin: + is_owner = await self._community_repo.check_post_ownership(post_id, user_id) + if not is_owner: + raise ForbiddenError("You can only delete your own posts") + + deleted_post = await self._community_repo.soft_delete_post(post_id) + if deleted_post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + return PostResponse.model_validate(deleted_post) diff --git a/backend/src/pequi/use_cases/get_post.py b/backend/src/pequi/use_cases/get_post.py new file mode 100644 index 0000000..0e961c5 --- /dev/null +++ b/backend/src/pequi/use_cases/get_post.py @@ -0,0 +1,17 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import PostResponse + + +class GetPostUseCase: + def __init__(self, community_repo: CommunityRepository) -> None: + self._community_repo = community_repo + + async def execute(self, post_id: UUID) -> PostResponse: + """Retorna um post específico por ID.""" + post = await self._community_repo.get_post_by_id(post_id) + if post is None: + raise NotFoundError("CommunityPost", str(post_id)) + return PostResponse.model_validate(post) diff --git a/backend/src/pequi/use_cases/list_comments.py b/backend/src/pequi/use_cases/list_comments.py new file mode 100644 index 0000000..cce56e9 --- /dev/null +++ b/backend/src/pequi/use_cases/list_comments.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import CommentListResponse, CommentResponse + + +class ListCommentsUseCase: + def __init__(self, community_repo: CommunityRepository) -> None: + self._community_repo = community_repo + + async def execute( + self, + post_id: UUID, + *, + limit: int = 50, + offset: int = 0, + ) -> CommentListResponse: + """Lista comentários de um post.""" + post = await self._community_repo.get_post_by_id(post_id) + if post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + comments, total = await self._community_repo.list_comments( + post_id=post_id, + limit=limit, + offset=offset, + ) + return CommentListResponse( + items=[CommentResponse.model_validate(c) for c in comments], + total=total, + ) diff --git a/backend/src/pequi/use_cases/list_posts.py b/backend/src/pequi/use_cases/list_posts.py new file mode 100644 index 0000000..3627823 --- /dev/null +++ b/backend/src/pequi/use_cases/list_posts.py @@ -0,0 +1,26 @@ +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import PostListResponse, PostResponse + + +class ListPostsUseCase: + def __init__(self, community_repo: CommunityRepository) -> None: + self._community_repo = community_repo + + async def execute( + self, + *, + limit: int = 50, + offset: int = 0, + category: str | None = None, + ) -> PostListResponse: + """Lista posts da comunidade paginados.""" + posts, total = await self._community_repo.list_posts( + limit=limit, + offset=offset, + category=category, + exclude_moderated=True, + ) + return PostListResponse( + items=[PostResponse.model_validate(p) for p in posts], + total=total, + ) diff --git a/backend/src/pequi/use_cases/moderate_post.py b/backend/src/pequi/use_cases/moderate_post.py new file mode 100644 index 0000000..b57a727 --- /dev/null +++ b/backend/src/pequi/use_cases/moderate_post.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.core.logging import get_logger +from pequi.repositories.audit_repo import AuditRepository +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import PostModerate, PostResponse + +logger = get_logger(__name__) + + +class ModeratePostUseCase: + def __init__( + self, + community_repo: CommunityRepository, + audit_repo: AuditRepository, + ) -> None: + self._community_repo = community_repo + self._audit_repo = audit_repo + + async def execute(self, post_id: UUID, data: PostModerate, admin_user_id: UUID) -> PostResponse: + """Modera post (admin only) — ação auditada em tabela audit_logs.""" + post = await self._community_repo.get_post_by_id(post_id) + if post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + updated_post = await self._community_repo.update_post_moderation(post_id, data.is_moderated) + if updated_post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + # Persistir auditoria em tabela (LGPD compliance - append-only) + await self._audit_repo.log_action( + actor_user_id=admin_user_id, + actor_role="admin", + entity_type="community_post", + entity_id=str(post_id), + action="moderate", + details=f"Set is_moderated={data.is_moderated}", + ) + + # Log estruturado adicional para observabilidade + logger.info( + "community.post_moderated", + admin_user_id=str(admin_user_id), + post_id=str(post_id), + is_moderated=data.is_moderated, + ) + + return PostResponse.model_validate(updated_post) diff --git a/backend/src/pequi/use_cases/toggle_like.py b/backend/src/pequi/use_cases/toggle_like.py new file mode 100644 index 0000000..2f6a4b6 --- /dev/null +++ b/backend/src/pequi/use_cases/toggle_like.py @@ -0,0 +1,39 @@ +from uuid import UUID + +from sqlalchemy.exc import IntegrityError + +from pequi.core.exceptions import ConflictError, NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.repositories.patient_repo import PatientRepository + + +class ToggleLikeUseCase: + def __init__( + self, + community_repo: CommunityRepository, + patient_repo: PatientRepository, + ) -> None: + self._community_repo = community_repo + self._patient_repo = patient_repo + + async def execute(self, user_id: UUID, post_id: UUID) -> dict: + """Toggle like em post — retorna (liked, like_count). + + Se já existe like, retorna 409 Conflict (PEQ-108). + Se não existe, cria like e retorna 200. + """ + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + post = await self._community_repo.get_post_by_id(post_id) + if post is None: + raise NotFoundError("CommunityPost", str(post_id)) + + # Criar like (atômico - trata IntegrityError para duplicatas) + try: + liked, like_count = await self._community_repo.add_like(user_id, post_id) + except IntegrityError: + raise ConflictError("You already liked this post") from None + + return {"liked": liked, "like_count": like_count} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f239296..7beeca3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ from sqlalchemy.pool import NullPool import pequi.models # noqa: F401 — registra todas as tabelas no metadata antes do create_all +import pequi.models.community # noqa: F401 — ensure community models are registered from pequi.config import get_settings from pequi.core.dependencies import get_db from pequi.core.rate_limit import limiter, user_limiter diff --git a/backend/tests/integration/test_community_flow.py b/backend/tests/integration/test_community_flow.py new file mode 100644 index 0000000..de26ec4 --- /dev/null +++ b/backend/tests/integration/test_community_flow.py @@ -0,0 +1,408 @@ +"""Testes de integração — fluxo da comunidade anônima (M6 / PEQ-106).""" + +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.community import CommentCreate, PostCreate +from pequi.use_cases.create_comment import CreateCommentUseCase +from pequi.use_cases.create_post import CreatePostUseCase +from pequi.use_cases.deanonymize import DeanonymizeUseCase +from pequi.use_cases.delete_post import DeletePostUseCase +from pequi.use_cases.get_post import GetPostUseCase +from pequi.use_cases.list_comments import ListCommentsUseCase +from pequi.use_cases.list_posts import ListPostsUseCase +from pequi.use_cases.moderate_post import ModeratePostUseCase +from pequi.use_cases.toggle_like import ToggleLikeUseCase +from tests.integration.test_dose_flow import ( + _create_health_unit, + _create_patient, + _create_user, +) + + +async def _create_admin_user(session, email: str = "admin@test.com"): + from pequi.models.user import User + + user = User( + id=uuid4(), + email=email, + hashed_password="hashed", + full_name="Admin User", + role="admin", + ) + session.add(user) + await session.flush() + return user + + +@pytest.mark.asyncio +async def test_patient_creates_post_anonymously(create_tables, db_session): + """Patient creates post — only anonymous_id appears in response.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient1@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + data = PostCreate( + title="Minha experiência com o tratamento", + content="Estou compartilhando minha jornada...", + category="experience", + ) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + use_case = CreatePostUseCase(community_repo, patient_repo) + result = await use_case.execute(patient_user.id, data) + + # Verify response has anonymous_id, not user_id + assert hasattr(result, "author_anonymous_id") + assert result.author_anonymous_id is not None + assert not hasattr(result, "user_id") + + # Verify the mapping was created + mapping = await community_repo.deanonymize(result.author_anonymous_id) + assert mapping is not None + assert mapping.user_id == patient_user.id + + +@pytest.mark.asyncio +async def test_anonymous_id_is_stable_for_user(create_tables, db_session): + """Anonymous ID remains the same across multiple posts from the same user.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient2@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + use_case = CreatePostUseCase(community_repo, patient_repo) + + data1 = PostCreate(title="Post 1", content="Content 123", category="experience") + post1 = await use_case.execute(patient_user.id, data1) + + data2 = PostCreate(title="Post 2", content="Content 456", category="question") + post2 = await use_case.execute(patient_user.id, data2) + + # Same anonymous_id for both posts + assert post1.author_anonymous_id == post2.author_anonymous_id + + +@pytest.mark.asyncio +async def test_different_users_have_different_anonymous_ids(create_tables, db_session): + """Different users get different anonymous IDs.""" + health_unit = await _create_health_unit(db_session) + user1 = await _create_user(db_session, email="user1@test.com", role="patient") + user2 = await _create_user(db_session, email="user2@test.com", role="patient") + await _create_patient(db_session, user=user1, health_unit=health_unit) + await _create_patient(db_session, user=user2, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + use_case = CreatePostUseCase(community_repo, patient_repo) + + data = PostCreate(title="Test", content="Test content", category="experience") + post1 = await use_case.execute(user1.id, data) + post2 = await use_case.execute(user2.id, data) + + # Different anonymous_ids + assert post1.author_anonymous_id != post2.author_anonymous_id + + +@pytest.mark.asyncio +async def test_user_id_never_appears_in_post_response(create_tables, db_session): + """Critical: user_id must never appear in any response field.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient3@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + use_case = CreatePostUseCase(community_repo, patient_repo) + + data = PostCreate(title="Test", content="Test content", category="experience") + result = await use_case.execute(patient_user.id, data) + + # Convert to dict to check all fields + response_dict = result.model_dump() + assert "user_id" not in response_dict + assert "author_anonymous_id" in response_dict + + +@pytest.mark.asyncio +async def test_patient_can_comment_on_post(create_tables, db_session): + """Patient can comment on a post anonymously.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient4@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + # Create comment + comment_data = CommentCreate(content="Great post!") + comment_use_case = CreateCommentUseCase(community_repo, patient_repo) + comment = await comment_use_case.execute(patient_user.id, post.id, comment_data) + + # Verify comment has anonymous_id, not user_id + assert comment.author_anonymous_id is not None + assert not hasattr(comment, "user_id") + + # Verify post comment count incremented + updated_post = await community_repo.get_post_by_id(post.id) + assert updated_post.comment_count == 1 + + +@pytest.mark.asyncio +async def test_duplicate_like_returns_409_conflict(create_tables, db_session): + """Duplicate like returns 409 Conflict (PEQ-108).""" + from pequi.core.exceptions import ConflictError + + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient12@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + # Like post (should succeed) + like_use_case = ToggleLikeUseCase(community_repo, patient_repo) + result1 = await like_use_case.execute(patient_user.id, post.id) + assert result1["liked"] is True + assert result1["like_count"] == 1 + + # Try to like again (should return 409 Conflict) + with pytest.raises(ConflictError): + await like_use_case.execute(patient_user.id, post.id) + + +@pytest.mark.asyncio +async def test_list_posts_excludes_moderated_content(create_tables, db_session): + """List posts should exclude moderated posts by default.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient6@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create two posts + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post1 = await post_use_case.execute(patient_user.id, post_data) + post2 = await post_use_case.execute(patient_user.id, post_data) + + # Moderate one post + await community_repo.update_post_moderation(post1.id, is_moderated=True) + + # List posts (should exclude moderated) + list_use_case = ListPostsUseCase(community_repo) + result = await list_use_case.execute() + + # Only non-moderated post should appear + assert len(result.items) == 1 + assert result.items[0].id == post2.id + + +@pytest.mark.asyncio +async def test_user_can_delete_own_post(create_tables, db_session): + """User can delete their own post (soft delete).""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient7@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + # Delete post + delete_use_case = DeletePostUseCase(community_repo) + await delete_use_case.execute(patient_user.id, post.id) + + # Verify post no longer appears in list + list_use_case = ListPostsUseCase(community_repo) + result = await list_use_case.execute() + assert len(result.items) == 0 + + +@pytest.mark.asyncio +async def test_user_cannot_delete_others_post(create_tables, db_session): + """User cannot delete another user's post.""" + health_unit = await _create_health_unit(db_session) + user1 = await _create_user(db_session, email="user1@test.com", role="patient") + user2 = await _create_user(db_session, email="user2@test.com", role="patient") + await _create_patient(db_session, user=user1, health_unit=health_unit) + await _create_patient(db_session, user=user2, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create post as user1 + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(user1.id, post_data) + + # Try to delete as user2 (should fail) + delete_use_case = DeletePostUseCase(community_repo) + with pytest.raises(ForbiddenError): + await delete_use_case.execute(user2.id, post.id) + + +@pytest.mark.asyncio +async def test_admin_can_moderate_post(create_tables, db_session): + """Admin can moderate posts (audit logged in audit_logs table).""" + from sqlalchemy import select + + from pequi.models.audit_log import AuditLog + from pequi.repositories.audit_repo import AuditRepository + + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient8@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + admin_user = await _create_admin_user(db_session) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + audit_repo = AuditRepository(db_session) + + # Create post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + # Moderate post as admin + from pequi.schemas.community import PostModerate + + moderate_use_case = ModeratePostUseCase(community_repo, audit_repo) + moderated_post = await moderate_use_case.execute( + post.id, PostModerate(is_moderated=True), admin_user.id + ) + + assert moderated_post.is_moderated is True + + # Verify audit log was persisted + stmt = select(AuditLog).where( + AuditLog.entity_type == "community_post", + AuditLog.action == "moderate", + ) + result = await db_session.execute(stmt) + audit_log = result.scalar_one_or_none() + assert audit_log is not None + assert audit_log.actor_user_id == admin_user.id + assert audit_log.actor_role == "admin" + assert audit_log.entity_id == str(post.id) + + +@pytest.mark.asyncio +async def test_admin_can_deanonymize_with_audit(create_tables, db_session): + """Admin can deanonymize (audit logged in audit_logs table).""" + from pequi.repositories.audit_repo import AuditRepository + + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient9@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + admin_user = await _create_admin_user(db_session) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + audit_repo = AuditRepository(db_session) + + # Create post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + # Deanonymize as admin + deanonymize_use_case = DeanonymizeUseCase(community_repo, audit_repo) + mapping = await deanonymize_use_case.execute(post.author_anonymous_id, admin_user.id) + + # Verify mapping exposes real user_id + assert mapping.user_id == patient_user.id + assert mapping.anonymous_id == post.author_anonymous_id + + # Verify audit log was persisted + from sqlalchemy import select + + from pequi.models.audit_log import AuditLog + + stmt = select(AuditLog).where( + AuditLog.entity_type == "community_anonymous_map", + AuditLog.action == "deanonymize", + ) + result = await db_session.execute(stmt) + audit_log = result.scalar_one_or_none() + assert audit_log is not None + assert audit_log.actor_user_id == admin_user.id + assert audit_log.actor_role == "admin" + assert audit_log.entity_id == str(post.author_anonymous_id) + + +@pytest.mark.asyncio +async def test_soft_deleted_posts_not_visible(create_tables, db_session): + """Soft deleted posts should not be visible in lists or get by ID.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient10@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create and delete post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + await community_repo.soft_delete_post(post.id) + + # Should not appear in list + list_use_case = ListPostsUseCase(community_repo) + result = await list_use_case.execute() + assert len(result.items) == 0 + + # Should not be accessible by ID + get_use_case = GetPostUseCase(community_repo) + with pytest.raises(NotFoundError): + await get_use_case.execute(post.id) + + +@pytest.mark.asyncio +async def test_list_comments_for_post(create_tables, db_session): + """List comments for a specific post.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient11@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + # Create post + post_data = PostCreate(title="Test", content="Test content", category="experience") + post_use_case = CreatePostUseCase(community_repo, patient_repo) + post = await post_use_case.execute(patient_user.id, post_data) + + # Create comments + comment_use_case = CreateCommentUseCase(community_repo, patient_repo) + await comment_use_case.execute(patient_user.id, post.id, CommentCreate(content="Comment 1")) + await comment_use_case.execute(patient_user.id, post.id, CommentCreate(content="Comment 2")) + + # List comments + list_comments_use_case = ListCommentsUseCase(community_repo) + result = await list_comments_use_case.execute(post.id) + + assert len(result.items) == 2 + assert result.total == 2 diff --git a/backend/tests/unit/test_community_anonymization.py b/backend/tests/unit/test_community_anonymization.py new file mode 100644 index 0000000..0753ca6 --- /dev/null +++ b/backend/tests/unit/test_community_anonymization.py @@ -0,0 +1,103 @@ +import pytest +from pydantic import ValidationError + +from pequi.schemas.community import CommentCreate, PostCreate + + +def test_post_create_title_min_length(): + """Post title must be at least 3 characters.""" + with pytest.raises(ValidationError): + PostCreate(title="ab", content="Valid content", category="experience") + + +def test_post_create_title_max_length(): + """Post title must be at most 200 characters.""" + with pytest.raises(ValidationError): + PostCreate(title="a" * 201, content="Valid content", category="experience") + + +def test_post_create_content_min_length(): + """Post content must be at least 10 characters.""" + with pytest.raises(ValidationError): + PostCreate(title="Valid title", content="short", category="experience") + + +def test_post_create_content_max_length(): + """Post content must be at most 5000 characters.""" + with pytest.raises(ValidationError): + PostCreate(title="Valid title", content="a" * 5001, category="experience") + + +def test_post_create_category_must_be_valid(): + """Post category must be one of: experience, question, support, news.""" + with pytest.raises(ValidationError): + PostCreate(title="Valid title", content="Valid content", category="invalid") + + # Valid categories should pass + for category in ["experience", "question", "support", "news"]: + PostCreate(title="Valid title", content="Valid content", category=category) + + +def test_comment_create_content_min_length(): + """Comment content must be at least 3 characters.""" + with pytest.raises(ValidationError): + CommentCreate(content="ab") + + +def test_comment_create_content_max_length(): + """Comment content must be at most 2000 characters.""" + with pytest.raises(ValidationError): + CommentCreate(content="a" * 2001) + + +def test_post_response_never_exposes_user_id(): + """PostResponse must never have user_id field — only author_anonymous_id.""" + from uuid import uuid4 + + from pequi.schemas.community import PostResponse + + # Verify that user_id is not in the response model fields + response_fields = PostResponse.model_fields + assert "user_id" not in response_fields + assert "author_anonymous_id" in response_fields + + # Verify that a valid response can be created with anonymous_id + post_data = { + "id": uuid4(), + "author_anonymous_id": uuid4(), + "title": "Test Post", + "content": "Test content", + "category": "experience", + "is_pinned": False, + "is_moderated": False, + "like_count": 0, + "comment_count": 0, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + response = PostResponse(**post_data) + assert response.author_anonymous_id is not None + + +def test_comment_response_never_exposes_user_id(): + """CommentResponse must never have user_id field — only author_anonymous_id.""" + from uuid import uuid4 + + from pequi.schemas.community import CommentResponse + + # Verify that user_id is not in the response model fields + response_fields = CommentResponse.model_fields + assert "user_id" not in response_fields + assert "author_anonymous_id" in response_fields + + # Verify that a valid response can be created with anonymous_id + comment_data = { + "id": uuid4(), + "post_id": uuid4(), + "author_anonymous_id": uuid4(), + "content": "Test comment", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + response = CommentResponse(**comment_data) + assert response.author_anonymous_id is not None From 538e7da1d73316f2b7b51b48ab9f158f0d4dc46b Mon Sep 17 00:00:00 2001 From: Leila Biggi <87096464+lawtherea@users.noreply.github.com> Date: Wed, 27 May 2026 12:59:11 -0300 Subject: [PATCH 21/69] PEQ-49-51-124: Profile feature and fixing some stuff in appointment feature (#28) * PEQ-49-51-124: Profile feature and fixing some stuff in appointment feature * fix: changed the medication tab to choose unit and frequency --- .../models/health-appointment.models.ts | 88 +- .../register-appointment.html | 557 +++++++--- .../register-appointment.ts | 452 ++++++-- .../services/health-appointment.service.ts | 95 +- .../services/patient-medication.service.ts | 29 + .../profile-edit-account.html | 145 +++ .../profile-edit-account.spec.ts | 31 + .../profile-edit-account.ts | 92 ++ .../profile-edit-personal.html | 480 +++++++++ .../profile-edit-personal.spec.ts | 81 ++ .../profile-edit-personal.ts | 192 ++++ .../profile/models/patient-profile.models.ts | 487 +++++++++ .../src/app/features/profile/profile.html | 992 +++++++++++++++++- .../src/app/features/profile/profile.spec.ts | 76 ++ frontend/src/app/features/profile/profile.ts | 480 ++++++++- .../services/patient-profile.service.spec.ts | 86 ++ .../services/patient-profile.service.ts | 168 +++ 17 files changed, 4231 insertions(+), 300 deletions(-) create mode 100644 frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html create mode 100644 frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts create mode 100644 frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts create mode 100644 frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.html create mode 100644 frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.spec.ts create mode 100644 frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.ts create mode 100644 frontend/src/app/features/profile/models/patient-profile.models.ts create mode 100644 frontend/src/app/features/profile/profile.spec.ts create mode 100644 frontend/src/app/features/profile/services/patient-profile.service.spec.ts create mode 100644 frontend/src/app/features/profile/services/patient-profile.service.ts diff --git a/frontend/src/app/features/appointments/models/health-appointment.models.ts b/frontend/src/app/features/appointments/models/health-appointment.models.ts index c5d2bdd..c3777cd 100644 --- a/frontend/src/app/features/appointments/models/health-appointment.models.ts +++ b/frontend/src/app/features/appointments/models/health-appointment.models.ts @@ -10,6 +10,40 @@ export type AppointmentType = (typeof APPOINTMENT_TYPES)[number]['value']; export type AppointmentStatus = 'scheduled' | 'completed'; +export type AnsGifGrade = '' | '0' | '1' | '2'; + +/** Avaliação Neurológica Simplificada (ANS) registrada na consulta. */ +export interface NeurologicalAssessmentRecord { + assessmentDate: string; + gifEye: AnsGifGrade; + gifHand: AnsGifGrade; + gifFoot: AnsGifGrade; + highestGif: AnsGifGrade; + ompSum: string; + conduct?: string; + ubs?: string; + reference?: string; +} + +export interface NeurologicalAssessmentDraft { + assessmentDate: string; + gifEye: AnsGifGrade; + gifHand: AnsGifGrade; + gifFoot: AnsGifGrade; + highestGif: AnsGifGrade; + ompSum: string; + conduct: string; + ubs: string; + reference: string; +} + +export const ANS_GIF_GRADE_OPTIONS: readonly { value: AnsGifGrade; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: '0', label: 'Grau 0' }, + { value: '1', label: 'Grau 1' }, + { value: '2', label: 'Grau 2' }, +] as const; + /** Dose mensal tomada no atendimento (sem troca de medicamento). */ export interface SupervisedDoseRecord { medicationId?: string; @@ -31,6 +65,7 @@ export interface AppointmentFollowUp { supervisedDose?: SupervisedDoseRecord; nextAppointmentDate?: string; guidanceReceived?: string; + neurologicalAssessment?: NeurologicalAssessmentRecord; } export interface HealthAppointment { @@ -53,13 +88,31 @@ export interface AppointmentFollowUpDraft { conduct: string; guidanceReceived: string; nextAppointmentDate: string; + doseMedicationChanged: boolean | null; + updateDoseFromConsultation: boolean; + doseSchemeClofazimina: boolean; + doseSchemeOfloxacino: boolean; + doseSchemeRifampicina: boolean; + doseSchemeMinociclina: boolean; + doseSchemeDapsone: boolean; + updateInstitutedMedsFromConsultation: boolean; hadMedicationChange: boolean | null; registerSupervisedDose: boolean; + registerNeurologicalAssessment: boolean; selectedMedicationId: string; otherMedicationName: string; medicationChangeDescription: string; - newMedicationName: string; - newDoseDescription: string; + institutedPrednisoneMgKg: string; + institutedAineMgDay: string; + institutedThalidomideMgDay: string; + institutedPentoxifyllineMgDay: string; + institutedOtherMedication: string; + institutedMedications: { + name: string; + dose: string; + unit: string; + frequency: string; + }[]; supervisedDoseNotes: string; } @@ -72,19 +125,45 @@ export interface HealthAppointmentDraft { notes: string; performed: boolean | null; followUp: AppointmentFollowUpDraft; + neurologicalAssessment: NeurologicalAssessmentDraft; } +export const EMPTY_NEUROLOGICAL_ASSESSMENT_DRAFT: NeurologicalAssessmentDraft = { + assessmentDate: '', + gifEye: '', + gifHand: '', + gifFoot: '', + highestGif: '', + ompSum: '', + conduct: '', + ubs: '', + reference: '', +}; + export const EMPTY_FOLLOW_UP_DRAFT: AppointmentFollowUpDraft = { conduct: '', guidanceReceived: '', nextAppointmentDate: '', + doseMedicationChanged: null, + updateDoseFromConsultation: false, + doseSchemeClofazimina: false, + doseSchemeOfloxacino: false, + doseSchemeRifampicina: false, + doseSchemeMinociclina: false, + doseSchemeDapsone: false, + updateInstitutedMedsFromConsultation: false, hadMedicationChange: null, registerSupervisedDose: false, + registerNeurologicalAssessment: false, selectedMedicationId: '', otherMedicationName: '', medicationChangeDescription: '', - newMedicationName: '', - newDoseDescription: '', + institutedPrednisoneMgKg: '', + institutedAineMgDay: '', + institutedThalidomideMgDay: '', + institutedPentoxifyllineMgDay: '', + institutedOtherMedication: '', + institutedMedications: [], supervisedDoseNotes: '', }; @@ -97,4 +176,5 @@ export const EMPTY_APPOINTMENT_DRAFT: HealthAppointmentDraft = { notes: '', performed: null, followUp: { ...EMPTY_FOLLOW_UP_DRAFT }, + neurologicalAssessment: { ...EMPTY_NEUROLOGICAL_ASSESSMENT_DRAFT }, }; diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.html b/frontend/src/app/features/appointments/register-appointment/register-appointment.html index 6cada7c..d9165df 100644 --- a/frontend/src/app/features/appointments/register-appointment/register-appointment.html +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.html @@ -208,9 +208,10 @@

Registrar consulta

@if (draft().performed === true) {

- Você pode incluir conduta, medicamentos e orientações (opcional). + Você pode incluir conduta, medicamentos, avaliação neurológica e orientações + (opcional).

- Registrar consulta }
-

Dose e medicamentos

- +

Dose mensal

+

+ Observação: alterações feitas aqui atualizam também o Perfil em Meu tratamento. +

+ @if (draft().followUp.registerSupervisedDose) { +
+ Mudança de medicamento? +
+ + +
+ @if (showValidation() && followUpForm.controls.doseMedicationChanged.value === null) { +

Informe se houve mudança de medicamento da dose.

+ } +
+ + @if (followUpForm.controls.doseMedicationChanged.value === false && !hasProfileDoseMedication()) { +
+ Não há dose mensal registrada no perfil. Preencha o esquema abaixo: vamos atualizar + automaticamente o Meu tratamento. +
+ } + @if (followUpForm.controls.doseMedicationChanged.value === false && hasProfileDoseMedication()) { +
+ Usando o medicamento informado em Meu tratamento: + {{ currentProfileDoseMedication() }}. +
+ } + + @if (shouldShowDoseRegistrationBlock()) { +
+
+ Sem dose registrada no perfil? + +
+
+ @for (opt of substituteSchemeMedicationOptions; track opt.key) { + + } +
+ @if (showValidation() && !( + draft().followUp.doseSchemeRifampicina || + draft().followUp.doseSchemeClofazimina || + draft().followUp.doseSchemeMinociclina || + draft().followUp.doseSchemeOfloxacino || + draft().followUp.doseSchemeDapsone + )) { +

Selecione pelo menos um medicamento da dose.

+ } +
+ } + } +
+ +
+

Medicamentos instituídos

+

+ Observação: alterações feitas aqui atualizam também o Perfil em Meu tratamento. +

- - Houve mudança de medicamento? - + Houve mudança?
- @if (showValidation() && draft().followUp.hadMedicationChange === null) { -

Informe se houve mudança de medicamento.

- }
- @if (draft().followUp.hadMedicationChange === false && draft().followUp.registerSupervisedDose) { -
-

- Selecione o medicamento que você está tomando. Em breve você poderá cadastrar - seus remédios no perfil. -

- - @if (patientMedications().length > 0) { -
- @for (med of patientMedications(); track med.id) { - - } - +
- } @else { -

- Nenhum medicamento no perfil ainda — informe o nome abaixo. -

} + + + } +
- @if ( - patientMedications().length === 0 || - draft().followUp.selectedMedicationId === 'other' - ) { -
- - -
- } +
+

Avaliação neurológica

+ + @if (isNeurologicalAssessmentType()) { +
+

+ Para consultas de avaliação neurológica, o registro da ANS já vem sugerido. Você + pode desmarcar se não quiser preencher agora. +

+
+ } + + + @if (draft().followUp.registerNeurologicalAssessment) { +
- @if (showValidation() && !draft().followUp.otherMedicationName.trim()) { -

Selecione ou informe o medicamento da dose.

- } -
- } +
+ GIF +
+
+ + +
+
+ + +
+
+ + +
+
+
- @if (draft().followUp.hadMedicationChange === true) { -
-

- Descreva a mudança e registre o novo medicamento com a dose indicada pelo - profissional. -

+
+
+ + +
+
+ + +
+
-
-
- @if (showValidation() && !followUpForm.controls.newDoseDescription.value?.trim()) { -

Informe a nova dose ou posologia.

- }
} - - @if ( - draft().followUp.hadMedicationChange === false && !draft().followUp.registerSupervisedDose - ) { -

- Você pode marcar o registro da dose acima quando quiser anotar o atendimento mensal. -

- }
@@ -498,7 +686,7 @@

Dose e medicamentos

class="w-full resize-none rounded-xl border border-[#E7E5E4] bg-[#FAFAF9] px-4 py-3 text-sm focus-visible:border-[#4338CA] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#4338CA]/30" >
- + } } @@ -540,12 +728,13 @@

Dose e medicamentos

{{ draft().notes }}
} - @if (draft().followUp.hadMedicationChange === true) { + @if (draft().followUp.updateInstitutedMedsFromConsultation) {
-
Mudança de medicamento
+
Medicamentos instituídos atualizados
- {{ draft().followUp.newMedicationName }} — - {{ draft().followUp.newDoseDescription }} + @for (med of draft().followUp.institutedMedications; track $index) { +

{{ med.name || 'Medicamento' }}: {{ med.dose || '—' }} {{ med.unit }}/{{ med.frequency }}

+ }
@if (draft().followUp.medicationChangeDescription) {
@@ -554,11 +743,55 @@

Dose e medicamentos

}
} - @if ( - draft().followUp.hadMedicationChange === false && - draft().followUp.registerSupervisedDose && - draft().followUp.otherMedicationName - ) { + @if (draft().followUp.registerNeurologicalAssessment && draft().performed) { +
+
Avaliação Neurológica Simplificada (ANS)
+
+ @if (draft().neurologicalAssessment.assessmentDate) { +

+ Data: + {{ formatDate(draft().neurologicalAssessment.assessmentDate) }} +

+ } + @if ( + draft().neurologicalAssessment.gifEye || + draft().neurologicalAssessment.gifHand || + draft().neurologicalAssessment.gifFoot + ) { +

+ GIF — Olho: + {{ ansGifGradeLabel(draft().neurologicalAssessment.gifEye) }} · Mão: + {{ ansGifGradeLabel(draft().neurologicalAssessment.gifHand) }} · Pé: + {{ ansGifGradeLabel(draft().neurologicalAssessment.gifFoot) }} +

+ } + @if (draft().neurologicalAssessment.highestGif) { +

+ Maior GIF: {{ ansGifGradeLabel(draft().neurologicalAssessment.highestGif) }} +

+ } + @if (draft().neurologicalAssessment.ompSum) { +

Soma OMP: {{ draft().neurologicalAssessment.ompSum }}

+ } + @if (draft().neurologicalAssessment.conduct) { +

+ Conduta: {{ draft().neurologicalAssessment.conduct }} +

+ } + @if (draft().neurologicalAssessment.ubs) { +

+ UBS: {{ draft().neurologicalAssessment.ubs }} +

+ } + @if (draft().neurologicalAssessment.reference) { +

+ Referência: {{ draft().neurologicalAssessment.reference }} +

+ } +
+
+ } + @if (draft().followUp.registerSupervisedDose && draft().followUp.otherMedicationName) {
Dose registrada
diff --git a/frontend/src/app/features/appointments/register-appointment/register-appointment.ts b/frontend/src/app/features/appointments/register-appointment/register-appointment.ts index 49d5e9d..4c4f31f 100644 --- a/frontend/src/app/features/appointments/register-appointment/register-appointment.ts +++ b/frontend/src/app/features/appointments/register-appointment/register-appointment.ts @@ -1,15 +1,30 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, signal } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { LucideAngularModule, LucideCalendar, LucideCheck, LucidePill } from 'lucide-angular'; import { + ANS_GIF_GRADE_OPTIONS, APPOINTMENT_TYPES, EMPTY_APPOINTMENT_DRAFT, + type AnsGifGrade, type HealthAppointmentDraft, + type NeurologicalAssessmentDraft, } from '../models/health-appointment.models'; import { HealthAppointmentService } from '../services/health-appointment.service'; import { PatientMedicationService } from '../services/patient-medication.service'; +import { + INSTITUTED_MEDICATION_FREQUENCY_OPTIONS, + INSTITUTED_MEDICATION_NAME_OPTIONS, + INSTITUTED_MEDICATION_OTHER_KEY, + INSTITUTED_MEDICATION_UNIT_OPTIONS, + SUBSTITUTE_SCHEME_MEDICATION_OPTIONS, + institutedMedicationSelectValue, + isInstitutedMedicationOtherKey, + parseInstitutedMedicationRows, + type PatientTreatmentData, +} from '../../profile/models/patient-profile.models'; +import { PatientProfileService } from '../../profile/services/patient-profile.service'; type WizardStepId = 'basics' | 'performed' | 'summary'; @@ -27,12 +42,18 @@ export class RegisterAppointmentComponent { private readonly router = inject(Router); private readonly appointmentService = inject(HealthAppointmentService); private readonly medicationService = inject(PatientMedicationService); + private readonly profileService = inject(PatientProfileService); readonly appointmentTypes = APPOINTMENT_TYPES; + readonly ansGifGradeOptions = ANS_GIF_GRADE_OPTIONS; + readonly substituteSchemeMedicationOptions = SUBSTITUTE_SCHEME_MEDICATION_OPTIONS; readonly patientMedications = this.medicationService.medications; readonly LucideCalendar = LucideCalendar; readonly LucideCheck = LucideCheck; readonly LucidePill = LucidePill; + readonly institutedMedicationNameOptions = INSTITUTED_MEDICATION_NAME_OPTIONS; + readonly institutedMedicationUnitOptions = INSTITUTED_MEDICATION_UNIT_OPTIONS; + readonly institutedMedicationFrequencyOptions = INSTITUTED_MEDICATION_FREQUENCY_OPTIONS; readonly currentStepIndex = signal(0); readonly showValidation = signal(false); @@ -42,6 +63,20 @@ export class RegisterAppointmentComponent { readonly draft = signal(structuredClone(EMPTY_APPOINTMENT_DRAFT)); readonly isSupervisedDoseType = computed(() => this.draft().type === 'dose_supervisionada'); + readonly isNeurologicalAssessmentType = computed( + () => this.draft().type === 'avaliacao_neurologica' + ); + readonly hasProfileDoseMedication = computed(() => { + const t = this.profileService.profile().treatment; + return ( + t.currentDoseMedication.trim().length > 0 || + t.schemeRifampicina || + t.schemeClofazimina || + t.schemeMinociclina || + t.schemeOfloxacino || + t.schemeDapsone + ); + }); readonly basicsForm = this.fb.group({ appointmentDate: ['', Validators.required], @@ -54,25 +89,114 @@ export class RegisterAppointmentComponent { readonly followUpForm = this.fb.group({ conduct: [''], + doseMedicationChanged: [null as boolean | null], + updateDoseFromConsultation: [false], + doseSchemeClofazimina: [false], + doseSchemeOfloxacino: [false], + doseSchemeRifampicina: [false], + doseSchemeMinociclina: [false], + doseSchemeDapsone: [false], + updateInstitutedMedsFromConsultation: [false], medicationChangeDescription: [''], - newMedicationName: [''], - newDoseDescription: [''], + institutedPrednisoneMgKg: [''], + institutedAineMgDay: [''], + institutedThalidomideMgDay: [''], + institutedPentoxifyllineMgDay: [''], + institutedOtherMedication: [''], + institutedMedications: this.fb.array([]), otherMedicationName: [''], supervisedDoseNotes: [''], nextAppointmentDate: [''], guidanceReceived: [''], }); - readonly wizardStepCount = WIZARD_STEP_COUNT; + get institutedMedicationsArray(): FormArray { + return this.followUpForm.controls.institutedMedications as FormArray; + } - readonly currentStepId = computed( - () => WIZARD_STEPS[this.currentStepIndex()] ?? 'basics' - ); + addInstitutedMedication(name = '', dose = '', unit = 'mg', frequency = 'dia'): void { + const medicationKey = institutedMedicationSelectValue(name); + this.institutedMedicationsArray.push( + this.fb.group({ + medicationKey: [medicationKey], + customName: [medicationKey === INSTITUTED_MEDICATION_OTHER_KEY ? name : ''], + dose: [dose], + unit: [unit], + frequency: [frequency], + }) + ); + } + + removeInstitutedMedication(index: number): void { + this.institutedMedicationsArray.removeAt(index); + } + + isInstitutedMedicationOther(index: number): boolean { + const key = this.institutedMedicationsArray.at(index)?.get('medicationKey')?.value; + return isInstitutedMedicationOtherKey(String(key ?? '')); + } + + readonly ansForm = this.fb.group({ + assessmentDate: [''], + gifEye: ['' as AnsGifGrade], + gifHand: ['' as AnsGifGrade], + gifFoot: ['' as AnsGifGrade], + highestGif: [{ value: '' as AnsGifGrade, disabled: true }], + ompSum: [{ value: '', disabled: true }], + conduct: [''], + ubs: [''], + reference: [''], + }); + constructor() { + this.ansForm.valueChanges.subscribe(() => this.updateAnsComputedGrades()); + const treatment = this.profileService.profile().treatment; + this.followUpForm.patchValue({ + doseSchemeClofazimina: treatment.schemeClofazimina, + doseSchemeOfloxacino: treatment.schemeOfloxacino, + doseSchemeRifampicina: treatment.schemeRifampicina, + doseSchemeMinociclina: treatment.schemeMinociclina, + doseSchemeDapsone: treatment.schemeDapsone, + institutedPrednisoneMgKg: treatment.prednisoneMgKg, + institutedAineMgDay: treatment.aineMgDay, + institutedThalidomideMgDay: treatment.thalidomideMgDay, + institutedPentoxifyllineMgDay: treatment.pentoxifyllineMgDay, + institutedOtherMedication: treatment.otherMedication, + }); + this.loadInstitutedMedicationsFromTreatment(treatment); + } + + private loadInstitutedMedicationsFromTreatment(treatment: PatientTreatmentData): void { + this.institutedMedicationsArray.clear(); + const stored = treatment.institutedMedications ?? []; + if (stored.length > 0) { + for (const item of stored) { + this.addInstitutedMedication(item.name, item.dose, item.unit || 'mg', item.frequency || 'dia'); + } + return; + } + if (treatment.prednisoneMgKg.trim()) { + this.addInstitutedMedication('Prednisona', treatment.prednisoneMgKg, 'mg/kg', 'dia'); + } + if (treatment.aineMgDay.trim()) { + this.addInstitutedMedication('AINE', treatment.aineMgDay, 'mg', 'dia'); + } + if (treatment.thalidomideMgDay.trim()) { + this.addInstitutedMedication('Talidomida', treatment.thalidomideMgDay, 'mg', 'dia'); + } + if (treatment.pentoxifyllineMgDay.trim()) { + this.addInstitutedMedication('Pentoxifilina', treatment.pentoxifyllineMgDay, 'mg', 'dia'); + } + if (treatment.otherMedication.trim()) { + this.addInstitutedMedication(treatment.otherMedication, '', 'mg', 'dia'); + } + } + + readonly wizardStepCount = WIZARD_STEP_COUNT; + readonly currentStepId = computed(() => WIZARD_STEPS[this.currentStepIndex()] ?? 'basics'); readonly progressPercentage = computed( () => ((this.currentStepIndex() + 1) / WIZARD_STEP_COUNT) * 100 ); - readonly stepLabel = computed(() => { switch (this.currentStepId()) { case 'basics': @@ -85,19 +209,14 @@ export class RegisterAppointmentComponent { return ''; } }); - readonly typeLabel = computed(() => { const type = this.draft().type; return APPOINTMENT_TYPES.find((t) => t.value === type)?.label ?? ''; }); - - readonly selectedMedicationLabel = computed(() => { - const fu = this.draft().followUp; - if (fu.selectedMedicationId && fu.selectedMedicationId !== 'other') { - return this.medicationService.findById(fu.selectedMedicationId)?.name ?? ''; - } - return fu.otherMedicationName; - }); + readonly selectedMedicationLabel = computed(() => this.draft().followUp.otherMedicationName); + readonly currentProfileDoseMedication = computed( + () => this.profileService.profile().treatment.currentDoseMedication.trim() || 'Não informado' + ); onPerformedChange(value: boolean): void { this.draft.update((d) => { @@ -105,8 +224,20 @@ export class RegisterAppointmentComponent { if (value && d.type === 'dose_supervisionada') { next.followUp = { ...d.followUp, registerSupervisedDose: true }; } + if (value && d.type === 'avaliacao_neurologica') { + next.followUp = { ...d.followUp, registerNeurologicalAssessment: true }; + if (!d.neurologicalAssessment.assessmentDate) { + next.neurologicalAssessment = { ...d.neurologicalAssessment, assessmentDate: d.appointmentDate }; + } + } return next; }); + if (value && this.draft().followUp.registerSupervisedDose) { + this.preselectCurrentDoseMedication(); + } + if (value && this.draft().followUp.registerNeurologicalAssessment) { + this.syncAnsFormFromDraft(this.draft().neurologicalAssessment); + } this.showValidation.set(false); } @@ -115,77 +246,91 @@ export class RegisterAppointmentComponent { ...d, followUp: { ...d.followUp, registerSupervisedDose: checked }, })); + if (checked) { + this.preselectCurrentDoseMedication(); + } else { + this.followUpForm.patchValue({ doseMedicationChanged: null, updateDoseFromConsultation: false }); + } this.showValidation.set(false); } - onHadMedicationChange(value: boolean): void { - this.draft.update((d) => ({ - ...d, - followUp: { - ...d.followUp, - hadMedicationChange: value, - selectedMedicationId: value ? '' : d.followUp.selectedMedicationId, - otherMedicationName: value ? '' : d.followUp.otherMedicationName, - medicationChangeDescription: value ? d.followUp.medicationChangeDescription : '', - newMedicationName: value ? d.followUp.newMedicationName : '', - newDoseDescription: value ? d.followUp.newDoseDescription : '', - }, - })); + onDoseMedicationChanged(value: boolean): void { + const requiresRegistration = !this.hasProfileDoseMedication(); this.followUpForm.patchValue({ - medicationChangeDescription: value ? this.draft().followUp.medicationChangeDescription : '', - newMedicationName: value ? this.draft().followUp.newMedicationName : '', - newDoseDescription: value ? this.draft().followUp.newDoseDescription : '', - otherMedicationName: value ? '' : this.draft().followUp.otherMedicationName, + doseMedicationChanged: value, + updateDoseFromConsultation: value || requiresRegistration, }); + if (value) { + this.preselectCurrentDoseMedication(); + } this.showValidation.set(false); } - onMedicationSelect(medicationId: string): void { - const med = this.medicationService.findById(medicationId); - this.draft.update((d) => ({ - ...d, - followUp: { - ...d.followUp, - selectedMedicationId: medicationId, - otherMedicationName: med?.name ?? '', - }, - })); - this.followUpForm.patchValue({ otherMedicationName: med?.name ?? '' }); - this.showValidation.set(false); - } - - onMedicationOtherSelect(): void { + onRegisterNeurologicalAssessmentChange(checked: boolean): void { this.draft.update((d) => ({ ...d, - followUp: { - ...d.followUp, - selectedMedicationId: 'other', - otherMedicationName: '', - }, + followUp: { ...d.followUp, registerNeurologicalAssessment: checked }, })); - this.followUpForm.patchValue({ otherMedicationName: '' }); + if (checked) { + this.syncAnsFormFromDraft(this.draft().neurologicalAssessment); + } this.showValidation.set(false); } - onOtherMedicationInput(event: Event): void { - const value = (event.target as HTMLInputElement).value; - this.draft.update((d) => ({ - ...d, - followUp: { ...d.followUp, otherMedicationName: value }, - })); + goToProfile(): void { + void this.router.navigate(['/profile']); } private syncFollowUpFromForm(): void { const raw = this.followUpForm.getRawValue(); + const institutedMedications = parseInstitutedMedicationRows( + this.institutedMedicationsArray.controls + ); + const byName = (name: string) => + institutedMedications.find((item) => item.name.toLowerCase() === name.toLowerCase())?.dose ?? ''; + const customMeds = institutedMedications + .filter( + (item) => + !['prednisona', 'aine', 'talidomida', 'pentoxifilina'].includes(item.name.toLowerCase()) + ) + .map((item) => `${item.name} ${item.dose} ${item.unit}/${item.frequency}`.trim()) + .join(' | '); + const doseSelected = [ + raw.doseSchemeRifampicina ? 'Rifampicina' : '', + raw.doseSchemeClofazimina ? 'Clofazimina' : '', + raw.doseSchemeMinociclina ? 'Minociclina' : '', + raw.doseSchemeOfloxacino ? 'Ofloxacino' : '', + raw.doseSchemeDapsone ? 'Dapsona' : '', + ] + .filter(Boolean) + .join(' + '); + const doseChanged = raw.doseMedicationChanged === true; + const shouldUpdateDose = raw.updateDoseFromConsultation ?? false; + const profileDose = this.profileService.profile().treatment.currentDoseMedication.trim(); + const doseName = shouldUpdateDose ? doseSelected : profileDose; + this.draft.update((d) => ({ ...d, followUp: { ...d.followUp, conduct: raw.conduct ?? '', + doseMedicationChanged: raw.doseMedicationChanged ?? null, + updateDoseFromConsultation: shouldUpdateDose, + doseSchemeClofazimina: raw.doseSchemeClofazimina ?? false, + doseSchemeOfloxacino: raw.doseSchemeOfloxacino ?? false, + doseSchemeRifampicina: raw.doseSchemeRifampicina ?? false, + doseSchemeMinociclina: raw.doseSchemeMinociclina ?? false, + doseSchemeDapsone: raw.doseSchemeDapsone ?? false, + updateInstitutedMedsFromConsultation: raw.updateInstitutedMedsFromConsultation ?? false, + hadMedicationChange: raw.updateInstitutedMedsFromConsultation ?? false, medicationChangeDescription: raw.medicationChangeDescription ?? '', - newMedicationName: raw.newMedicationName ?? '', - newDoseDescription: raw.newDoseDescription ?? '', - otherMedicationName: raw.otherMedicationName ?? d.followUp.otherMedicationName, + institutedPrednisoneMgKg: byName('Prednisona'), + institutedAineMgDay: byName('AINE'), + institutedThalidomideMgDay: byName('Talidomida'), + institutedPentoxifyllineMgDay: byName('Pentoxifilina'), + institutedOtherMedication: customMeds, + institutedMedications, + otherMedicationName: doseName || '', supervisedDoseNotes: raw.supervisedDoseNotes ?? '', nextAppointmentDate: raw.nextAppointmentDate ?? '', guidanceReceived: raw.guidanceReceived ?? '', @@ -193,78 +338,142 @@ export class RegisterAppointmentComponent { })); } + private syncAnsFromForm(): void { + const raw = this.ansForm.getRawValue(); + this.updateAnsComputedGrades(); + const computed = this.ansForm.getRawValue(); + const assessment: NeurologicalAssessmentDraft = { + assessmentDate: raw.assessmentDate ?? '', + gifEye: (raw.gifEye ?? '') as AnsGifGrade, + gifHand: (raw.gifHand ?? '') as AnsGifGrade, + gifFoot: (raw.gifFoot ?? '') as AnsGifGrade, + highestGif: (computed.highestGif ?? '') as AnsGifGrade, + ompSum: computed.ompSum ?? '', + conduct: raw.conduct ?? '', + ubs: raw.ubs ?? '', + reference: raw.reference ?? '', + }; + this.draft.update((d) => ({ ...d, neurologicalAssessment: assessment })); + } + + private updateAnsComputedGrades(): void { + const raw = this.ansForm.getRawValue(); + const grades = [raw.gifEye, raw.gifHand, raw.gifFoot] + .filter((grade): grade is Exclude => grade === '0' || grade === '1' || grade === '2') + .map((grade) => Number(grade)); + if (grades.length === 0) { + this.ansForm.patchValue({ highestGif: '', ompSum: '' }, { emitEvent: false }); + return; + } + this.ansForm.patchValue( + { + highestGif: String(Math.max(...grades)) as AnsGifGrade, + ompSum: String(grades.reduce((sum, grade) => sum + grade, 0)), + }, + { emitEvent: false } + ); + } + + private syncAnsFormFromDraft(assessment: NeurologicalAssessmentDraft): void { + this.ansForm.patchValue( + { + assessmentDate: assessment.assessmentDate, + gifEye: assessment.gifEye, + gifHand: assessment.gifHand, + gifFoot: assessment.gifFoot, + conduct: assessment.conduct, + ubs: assessment.ubs, + reference: assessment.reference, + }, + { emitEvent: false } + ); + this.updateAnsComputedGrades(); + } + + ansGifGradeLabel(value: AnsGifGrade): string { + return ANS_GIF_GRADE_OPTIONS.find((option) => option.value === value)?.label ?? '—'; + } + + private preselectCurrentDoseMedication(): void { + const treatment = this.profileService.profile().treatment; + this.followUpForm.patchValue({ + doseSchemeClofazimina: treatment.schemeClofazimina, + doseSchemeOfloxacino: treatment.schemeOfloxacino, + doseSchemeRifampicina: treatment.schemeRifampicina, + doseSchemeMinociclina: treatment.schemeMinociclina, + doseSchemeDapsone: treatment.schemeDapsone, + otherMedicationName: treatment.currentDoseMedication, + }); + if (!treatment.currentDoseMedication.trim()) { + this.followUpForm.patchValue({ doseMedicationChanged: true, updateDoseFromConsultation: true }); + } + } + private validateFollowUpStep(): boolean { this.syncFollowUpFromForm(); const fu = this.draft().followUp; - - if (fu.hadMedicationChange === true) { - const name = fu.newMedicationName.trim(); - const dose = fu.newDoseDescription.trim(); - if (!name || !dose) { - this.showValidation.set(true); - return false; - } - return true; + if (fu.registerSupervisedDose && fu.doseMedicationChanged === null) { + this.showValidation.set(true); + return false; } - - if (fu.registerSupervisedDose) { - const medName = fu.otherMedicationName.trim(); - const hasSelection = - (fu.selectedMedicationId && fu.selectedMedicationId !== 'other') || medName.length > 0; - if (!hasSelection) { + if (fu.registerSupervisedDose && fu.updateDoseFromConsultation) { + const hasDoseSelection = + fu.doseSchemeRifampicina || + fu.doseSchemeClofazimina || + fu.doseSchemeMinociclina || + fu.doseSchemeOfloxacino || + fu.doseSchemeDapsone; + if (!hasDoseSelection) { this.showValidation.set(true); return false; } } - return true; } + shouldShowDoseRegistrationBlock(): boolean { + return ( + this.followUpForm.controls.updateDoseFromConsultation.value === true || + (this.followUpForm.controls.doseMedicationChanged.value === false && !this.hasProfileDoseMedication()) + ); + } + + onInstitutedMedicationChanged(value: boolean): void { + this.followUpForm.patchValue({ updateInstitutedMedsFromConsultation: value }); + this.showValidation.set(false); + } + nextStep(): void { const stepId = this.currentStepId(); - if (stepId === 'basics' && this.basicsForm.invalid) { this.basicsForm.markAllAsTouched(); this.showValidation.set(true); return; } - if (stepId === 'basics') { const raw = this.basicsForm.getRawValue(); - this.draft.update((d) => { - const type = (raw.type ?? '') as HealthAppointmentDraft['type']; - const next: HealthAppointmentDraft = { - ...d, - appointmentDate: raw.appointmentDate ?? '', - appointmentTime: raw.appointmentTime ?? '', - location: raw.location ?? '', - type, - professional: raw.professional ?? '', - notes: raw.notes ?? '', - }; - if (type === 'dose_supervisionada' && d.performed === true) { - next.followUp = { ...d.followUp, registerSupervisedDose: true }; - } - return next; - }); + this.draft.update((d) => ({ + ...d, + appointmentDate: raw.appointmentDate ?? '', + appointmentTime: raw.appointmentTime ?? '', + location: raw.location ?? '', + type: (raw.type ?? '') as HealthAppointmentDraft['type'], + professional: raw.professional ?? '', + notes: raw.notes ?? '', + })); } - if (stepId === 'performed') { - const { performed } = this.draft(); - if (performed === null) { + if (this.draft().performed === null) { this.showValidation.set(true); return; } - if (performed === true && !this.validateFollowUpStep()) { - return; + if (this.draft().performed === true && !this.validateFollowUpStep()) return; + if (this.draft().performed === true && this.draft().followUp.registerNeurologicalAssessment) { + this.syncAnsFromForm(); } } - this.showValidation.set(false); - const maxIndex = WIZARD_STEP_COUNT - 1; - if (this.currentStepIndex() < maxIndex) { - this.currentStepIndex.update((i) => i + 1); - } + if (this.currentStepIndex() < WIZARD_STEP_COUNT - 1) this.currentStepIndex.update((i) => i + 1); } prevStep(): void { @@ -276,11 +485,38 @@ export class RegisterAppointmentComponent { submit(): void { this.syncFollowUpFromForm(); + if (this.draft().followUp.registerNeurologicalAssessment) this.syncAnsFromForm(); + this.syncProfileFromConsultation(); const record = this.appointmentService.saveFromDraft(this.draft()); this.savedStatusLabel.set(record.status === 'scheduled' ? 'Agendado' : 'Realizado'); this.saved.set(true); } + private syncProfileFromConsultation(): void { + if (this.draft().performed !== true) return; + const fu = this.draft().followUp; + const current = this.profileService.profile().treatment; + const next = { ...current }; + if (fu.registerSupervisedDose && fu.updateDoseFromConsultation) { + next.schemeClofazimina = fu.doseSchemeClofazimina; + next.schemeOfloxacino = fu.doseSchemeOfloxacino; + next.schemeRifampicina = fu.doseSchemeRifampicina; + next.schemeMinociclina = fu.doseSchemeMinociclina; + next.schemeDapsone = fu.doseSchemeDapsone; + next.currentDoseMedication = fu.otherMedicationName; + } + if (fu.updateInstitutedMedsFromConsultation) { + next.prednisoneMgKg = fu.institutedPrednisoneMgKg; + next.aineMgDay = fu.institutedAineMgDay; + next.thalidomideMgDay = fu.institutedThalidomideMgDay; + next.pentoxifyllineMgDay = fu.institutedPentoxifyllineMgDay; + next.otherMedication = fu.institutedOtherMedication; + next.institutedMedications = fu.institutedMedications; + } + this.profileService.updateTreatment(next); + this.medicationService.setCurrentDoseMedication(next.currentDoseMedication); + } + goHome(): void { void this.router.navigate(['/home']); } diff --git a/frontend/src/app/features/appointments/services/health-appointment.service.ts b/frontend/src/app/features/appointments/services/health-appointment.service.ts index d822cb4..e070b39 100644 --- a/frontend/src/app/features/appointments/services/health-appointment.service.ts +++ b/frontend/src/app/features/appointments/services/health-appointment.service.ts @@ -4,6 +4,7 @@ import type { AppointmentFollowUpDraft, HealthAppointment, HealthAppointmentDraft, + NeurologicalAssessmentDraft, } from '../models/health-appointment.models'; const STORAGE_KEY = 'pequi.health_appointments'; @@ -16,7 +17,7 @@ export class HealthAppointmentService { saveFromDraft(draft: HealthAppointmentDraft): HealthAppointment { const performed = draft.performed === true; - const followUp = performed ? this.buildFollowUp(draft.followUp) : undefined; + const followUp = performed ? this.buildFollowUpFromDraft(draft) : undefined; const record: HealthAppointment = { id: crypto.randomUUID(), appointmentDate: draft.appointmentDate, @@ -45,20 +46,36 @@ export class HealthAppointmentService { nextAppointmentDate: raw.nextAppointmentDate || undefined, }; - if (raw.hadMedicationChange === true) { + if (raw.updateInstitutedMedsFromConsultation) { result.hadMedicationChange = true; - const newName = raw.newMedicationName?.trim(); - const newDose = raw.newDoseDescription?.trim(); - if (newName && newDose) { - result.medicationChange = { - description: raw.medicationChangeDescription?.trim() || undefined, - newMedicationName: newName, - newDoseDescription: newDose, - }; - } - } else if (raw.hadMedicationChange === false && raw.registerSupervisedDose) { + const doseSummary = [ + `Prednisona ${raw.institutedPrednisoneMgKg || '—'} mg/kg`, + `AINE ${raw.institutedAineMgDay || '—'} mg/dia`, + `Talidomida ${raw.institutedThalidomideMgDay || '—'} mg/dia`, + `Pentoxifilina ${raw.institutedPentoxifyllineMgDay || '—'} mg/dia`, + raw.institutedOtherMedication + ? `Outro: ${raw.institutedOtherMedication}` + : undefined, + ] + .filter(Boolean) + .join(' | '); + result.medicationChange = { + description: raw.medicationChangeDescription?.trim() || undefined, + newMedicationName: 'Medicamentos instituídos atualizados', + newDoseDescription: doseSummary, + }; + } else if (raw.registerSupervisedDose) { result.hadMedicationChange = false; - const medName = raw.otherMedicationName?.trim(); + const selectedDose = [ + raw.doseSchemeRifampicina ? 'Rifampicina' : '', + raw.doseSchemeClofazimina ? 'Clofazimina' : '', + raw.doseSchemeMinociclina ? 'Minociclina' : '', + raw.doseSchemeOfloxacino ? 'Ofloxacino' : '', + raw.doseSchemeDapsone ? 'Dapsona' : '', + ] + .filter(Boolean) + .join(' + '); + const medName = selectedDose || raw.otherMedicationName?.trim(); if (medName) { result.supervisedDose = { medicationId: @@ -69,7 +86,7 @@ export class HealthAppointmentService { notes: raw.supervisedDoseNotes?.trim() || undefined, }; } - } else if (raw.hadMedicationChange === false) { + } else if (raw.hadMedicationChange === false || raw.updateInstitutedMedsFromConsultation === false) { result.hadMedicationChange = false; } @@ -84,6 +101,56 @@ export class HealthAppointmentService { return hasValue ? result : undefined; } + private buildFollowUpFromDraft(draft: HealthAppointmentDraft): AppointmentFollowUp | undefined { + const result = this.buildFollowUp(draft.followUp) ?? {}; + + if (draft.followUp.registerNeurologicalAssessment) { + const neurological = this.buildNeurologicalAssessmentRecord(draft.neurologicalAssessment); + if (neurological) { + result.neurologicalAssessment = neurological; + } + } + + const hasValue = + result.conduct !== undefined || + result.guidanceReceived !== undefined || + result.nextAppointmentDate !== undefined || + result.hadMedicationChange !== undefined || + result.medicationChange !== undefined || + result.supervisedDose !== undefined || + result.neurologicalAssessment !== undefined; + + return hasValue ? result : undefined; + } + + private buildNeurologicalAssessmentRecord( + raw: NeurologicalAssessmentDraft + ): AppointmentFollowUp['neurologicalAssessment'] | undefined { + const hasGif = + raw.gifEye !== '' || raw.gifHand !== '' || raw.gifFoot !== '' || raw.highestGif !== ''; + const hasDetails = + raw.assessmentDate !== '' || + hasGif || + raw.ompSum.trim() !== '' || + raw.conduct.trim() !== '' || + raw.ubs.trim() !== '' || + raw.reference.trim() !== ''; + + if (!hasDetails) return undefined; + + return { + assessmentDate: raw.assessmentDate, + gifEye: raw.gifEye, + gifHand: raw.gifHand, + gifFoot: raw.gifFoot, + highestGif: raw.highestGif, + ompSum: raw.ompSum, + conduct: raw.conduct.trim() || undefined, + ubs: raw.ubs.trim() || undefined, + reference: raw.reference.trim() || undefined, + }; + } + private loadFromStorage(): HealthAppointment[] { if (typeof localStorage === 'undefined') { return []; diff --git a/frontend/src/app/features/appointments/services/patient-medication.service.ts b/frontend/src/app/features/appointments/services/patient-medication.service.ts index 0dc6023..b50bd2c 100644 --- a/frontend/src/app/features/appointments/services/patient-medication.service.ts +++ b/frontend/src/app/features/appointments/services/patient-medication.service.ts @@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core'; import type { PatientMedication } from '../models/patient-medication.models'; const STORAGE_KEY = 'pequi.patient_medications'; +export const CURRENT_DOSE_MEDICATION_ID = 'current-dose'; /** * Medicamentos do perfil do paciente. Hoje lê do localStorage; @@ -18,6 +19,29 @@ export class PatientMedicationService { return this.medicationsSignal().find((m) => m.id === id); } + getCurrentDoseMedication(): PatientMedication | undefined { + return this.findById(CURRENT_DOSE_MEDICATION_ID); + } + + setCurrentDoseMedication(name: string): void { + const trimmed = name.trim(); + const others = this.medicationsSignal().filter((m) => m.id !== CURRENT_DOSE_MEDICATION_ID); + + if (!trimmed) { + this.medicationsSignal.set(others); + this.persist(others); + return; + } + + const current: PatientMedication = { + id: CURRENT_DOSE_MEDICATION_ID, + name: trimmed, + }; + const next = [current, ...others]; + this.medicationsSignal.set(next); + this.persist(next); + } + private loadFromStorage(): PatientMedication[] { if (typeof localStorage === 'undefined') { return []; @@ -31,4 +55,9 @@ export class PatientMedicationService { return []; } } + + private persist(items: PatientMedication[]): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } } diff --git a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html new file mode 100644 index 0000000..31202fc --- /dev/null +++ b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html @@ -0,0 +1,145 @@ + diff --git a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts new file mode 100644 index 0000000..659d972 --- /dev/null +++ b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ProfileEditAccount } from './profile-edit-account'; + +describe('ProfileEditAccount', () => { + let fixture: ComponentFixture; + let component: ProfileEditAccount; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProfileEditAccount], + }).compileComponents(); + + fixture = TestBed.createComponent(ProfileEditAccount); + component = fixture.componentInstance; + fixture.componentRef.setInput('initialEmail', 'a@b.com'); + fixture.componentRef.setInput('hasPassword', false); + fixture.detectChanges(); + }); + + it('should reject invalid email', () => { + component.form.patchValue({ loginEmail: 'invalid' }); + component.submit(); + expect(component.showValidation()).toBe(true); + }); + + it('should show password error message', () => { + fixture.componentRef.setInput('passwordError', 'wrong_current'); + fixture.detectChanges(); + expect(component.passwordErrorMessage()).toBe('Senha atual incorreta.'); + }); +}); diff --git a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts new file mode 100644 index 0000000..1053382 --- /dev/null +++ b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts @@ -0,0 +1,92 @@ +import { Component, computed, effect, inject, input, output, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { LucideAngularModule, LucideX } from 'lucide-angular'; +import type { ChangePasswordError } from '../../services/patient-profile.service'; + +export type AccountSavePayload = { + loginEmail: string; + currentPassword: string; + newPassword: string; + confirmPassword: string; +}; + +@Component({ + selector: 'app-profile-edit-account', + standalone: true, + imports: [ReactiveFormsModule, LucideAngularModule], + templateUrl: './profile-edit-account.html', +}) +export class ProfileEditAccount { + private readonly fb = inject(FormBuilder); + + readonly initialEmail = input.required(); + readonly hasPassword = input(false); + readonly passwordError = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly LucideX = LucideX; + readonly showValidation = signal(false); + + readonly form = this.fb.group({ + loginEmail: ['', [Validators.required, Validators.email]], + currentPassword: [''], + newPassword: ['', Validators.minLength(6)], + confirmPassword: [''], + }); + + readonly passwordSectionTitle = computed(() => + this.hasPassword() ? 'Alterar senha' : 'Definir senha' + ); + + constructor() { + effect(() => { + this.form.patchValue({ loginEmail: this.initialEmail() }, { emitEvent: false }); + }); + } + + onBackdropClick(event: MouseEvent): void { + if (event.target === event.currentTarget) { + this.onClose(); + } + } + + onClose(): void { + this.closed.emit(); + } + + submit(): void { + const emailCtrl = this.form.controls.loginEmail; + const newPwd = this.form.controls.newPassword.value ?? ''; + const confirmPwd = this.form.controls.confirmPassword.value ?? ''; + + if (emailCtrl.invalid) { + this.showValidation.set(true); + return; + } + + const changingPassword = newPwd.length > 0 || confirmPwd.length > 0; + if (changingPassword && (this.form.controls.newPassword.invalid || newPwd !== confirmPwd)) { + this.showValidation.set(true); + return; + } + + this.saved.emit(this.form.getRawValue() as AccountSavePayload); + } + + passwordErrorMessage(): string | null { + const err = this.passwordError(); + if (!err) return null; + switch (err) { + case 'wrong_current': + return 'Senha atual incorreta.'; + case 'mismatch': + return 'A nova senha e a confirmação não coincidem.'; + case 'too_short': + return 'A senha deve ter pelo menos 6 caracteres.'; + default: + return null; + } + } +} diff --git a/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.html b/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.html new file mode 100644 index 0000000..41d963a --- /dev/null +++ b/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.html @@ -0,0 +1,480 @@ + diff --git a/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.spec.ts b/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.spec.ts new file mode 100644 index 0000000..2c480a3 --- /dev/null +++ b/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EMPTY_PERSONAL_DATA } from '../../models/patient-profile.models'; +import { ProfileEditPersonal } from './profile-edit-personal'; + +describe('ProfileEditPersonal', () => { + let fixture: ComponentFixture; + let component: ProfileEditPersonal; + let saved: typeof EMPTY_PERSONAL_DATA | null; + + beforeEach(async () => { + saved = null; + await TestBed.configureTestingModule({ + imports: [ProfileEditPersonal], + }).compileComponents(); + + fixture = TestBed.createComponent(ProfileEditPersonal); + component = fixture.componentInstance; + fixture.componentRef.setInput('initialData', { ...EMPTY_PERSONAL_DATA }); + fixture.detectChanges(); + component.saved.subscribe((data) => { + saved = data; + }); + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show display name hints on name fields', () => { + expect(fixture.nativeElement.querySelector('[data-testid="full-name-hint"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="social-name-hint"]')).toBeTruthy(); + }); + + it('should require full name on submit', () => { + component.form.patchValue({ fullName: '' }); + component.submit(); + expect(component.showValidation()).toBe(true); + expect(saved).toBeNull(); + }); + + it('should emit saved data with full name', () => { + component.form.patchValue({ + fullName: 'Carlos Lima', + cpf: '111.222.333-44', + }); + component.submit(); + expect(saved?.fullName).toBe('Carlos Lima'); + expect(saved?.cpf).toBe('111.222.333-44'); + }); + + it('should show indigenous ethnicity when race is indigena', () => { + component.form.controls.raceColor.setValue('indigena'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="indigenous-ethnicity"]')).toBeTruthy(); + }); + + it('should show gender identity fields when user wants to inform', () => { + component.form.controls.wantsGenderIdentity.setValue('sim'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="gender-identity"]')).toBeTruthy(); + }); + + it('should not save gender identity when user does not want to inform', () => { + component.form.patchValue({ + fullName: 'Teste', + wantsGenderIdentity: 'nao', + genderIdentity: 'travesti', + }); + component.submit(); + expect(saved?.genderIdentity).toBe(''); + expect(saved?.wantsGenderIdentity).toBe('nao'); + }); + + it('should emit closed on cancel', () => { + const closed = vi.fn(); + component.closed.subscribe(closed); + component.onClose(); + expect(closed).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.ts b/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.ts new file mode 100644 index 0000000..c9d6d08 --- /dev/null +++ b/frontend/src/app/features/profile/components/profile-edit-personal/profile-edit-personal.ts @@ -0,0 +1,192 @@ +import { Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { LucideAngularModule, LucideX } from 'lucide-angular'; +import { + BLOOD_TYPE_OPTIONS, + EDUCATION_OPTIONS, + GENDER_IDENTITY_OPTIONS, + MARITAL_STATUS_OPTIONS, + NATIONALITY_OPTIONS, + RACE_COLOR_OPTIONS, + SEX_OPTIONS, + SEXUAL_ORIENTATION_OPTIONS, + YES_NO_OPTIONS, + type PatientPersonalData, + type YesNoChoice, +} from '../../models/patient-profile.models'; + +@Component({ + selector: 'app-profile-edit-personal', + standalone: true, + imports: [ReactiveFormsModule, LucideAngularModule], + templateUrl: './profile-edit-personal.html', +}) +export class ProfileEditPersonal { + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + + readonly initialData = input.required(); + readonly saved = output(); + readonly closed = output(); + + readonly LucideX = LucideX; + readonly educationOptions = EDUCATION_OPTIONS; + readonly bloodTypeOptions = BLOOD_TYPE_OPTIONS; + readonly maritalStatusOptions = MARITAL_STATUS_OPTIONS; + readonly nationalityOptions = NATIONALITY_OPTIONS; + readonly raceColorOptions = RACE_COLOR_OPTIONS; + readonly sexOptions = SEX_OPTIONS; + readonly yesNoOptions = YES_NO_OPTIONS; + readonly genderIdentityOptions = GENDER_IDENTITY_OPTIONS; + readonly sexualOrientationOptions = SEXUAL_ORIENTATION_OPTIONS; + readonly showValidation = signal(false); + + readonly raceColor = signal(''); + readonly wantsGenderIdentity = signal(''); + readonly wantsSexualOrientation = signal(''); + readonly genderIdentity = signal(''); + readonly sexualOrientation = signal(''); + + readonly form = this.fb.group({ + fullName: ['', Validators.required], + socialName: [''], + cpf: [''], + susCard: [''], + birthDate: [''], + maritalStatus: [''], + nationality: [''], + raceColor: [''], + indigenousEthnicity: [''], + sex: [''], + wantsGenderIdentity: ['' as YesNoChoice], + genderIdentity: [''], + genderIdentityOther: [''], + wantsSexualOrientation: ['' as YesNoChoice], + sexualOrientation: [''], + sexualOrientationOther: [''], + address: [''], + phone: [''], + email: [''], + education: [''], + occupation: [''], + healthUnit: [''], + acsName: [''], + nurseName: [''], + doctorName: [''], + emergencyContact: [''], + bloodType: [''], + medicationAllergies: [''], + }); + + constructor() { + effect(() => { + const data = this.initialData(); + this.form.patchValue(data, { emitEvent: false }); + this.syncConditionalSignals(); + }); + + this.form.controls.raceColor.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.raceColor.set(v ?? ''); + if (v !== 'indigena') { + this.form.controls.indigenousEthnicity.setValue('', { emitEvent: false }); + } + }); + + this.form.controls.wantsGenderIdentity.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.wantsGenderIdentity.set((v ?? '') as YesNoChoice); + if (v !== 'sim') { + this.form.patchValue( + { genderIdentity: '', genderIdentityOther: '' }, + { emitEvent: false } + ); + this.genderIdentity.set(''); + } + }); + + this.form.controls.genderIdentity.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.genderIdentity.set(v ?? ''); + if (v !== 'outra') { + this.form.controls.genderIdentityOther.setValue('', { emitEvent: false }); + } + }); + + this.form.controls.wantsSexualOrientation.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.wantsSexualOrientation.set((v ?? '') as YesNoChoice); + if (v !== 'sim') { + this.form.patchValue( + { sexualOrientation: '', sexualOrientationOther: '' }, + { emitEvent: false } + ); + this.sexualOrientation.set(''); + } + }); + + this.form.controls.sexualOrientation.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.sexualOrientation.set(v ?? ''); + if (v !== 'outra') { + this.form.controls.sexualOrientationOther.setValue('', { emitEvent: false }); + } + }); + } + + onBackdropClick(event: MouseEvent): void { + if (event.target === event.currentTarget) { + this.onClose(); + } + } + + onClose(): void { + this.closed.emit(); + } + + submit(): void { + if (this.form.invalid) { + this.showValidation.set(true); + return; + } + this.saved.emit(this.buildPersonalData()); + } + + private buildPersonalData(): PatientPersonalData { + const raw = this.form.getRawValue() as PatientPersonalData; + if (raw.raceColor !== 'indigena') { + raw.indigenousEthnicity = ''; + } + if (raw.wantsGenderIdentity !== 'sim') { + raw.genderIdentity = ''; + raw.genderIdentityOther = ''; + } else if (raw.genderIdentity !== 'outra') { + raw.genderIdentityOther = ''; + } + if (raw.wantsSexualOrientation !== 'sim') { + raw.sexualOrientation = ''; + raw.sexualOrientationOther = ''; + } else if (raw.sexualOrientation !== 'outra') { + raw.sexualOrientationOther = ''; + } + return raw; + } + + private syncConditionalSignals(): void { + this.raceColor.set(this.form.controls.raceColor.value ?? ''); + this.wantsGenderIdentity.set( + (this.form.controls.wantsGenderIdentity.value ?? '') as YesNoChoice + ); + this.wantsSexualOrientation.set( + (this.form.controls.wantsSexualOrientation.value ?? '') as YesNoChoice + ); + this.genderIdentity.set(this.form.controls.genderIdentity.value ?? ''); + this.sexualOrientation.set(this.form.controls.sexualOrientation.value ?? ''); + } +} diff --git a/frontend/src/app/features/profile/models/patient-profile.models.ts b/frontend/src/app/features/profile/models/patient-profile.models.ts new file mode 100644 index 0000000..baa977c --- /dev/null +++ b/frontend/src/app/features/profile/models/patient-profile.models.ts @@ -0,0 +1,487 @@ +export type LeprosyClassification = '' | 'PB' | 'MB'; + +export type YesNoChoice = '' | 'sim' | 'nao'; + +export type ClinicalForm = '' | 'I' | 'T' | 'D' | 'V'; + +export type GifGrade = '' | 'grau_0' | 'grau_1' | 'grau_2'; + +export type SubstituteSchemeMedication = + | 'clofazimina' + | 'ofloxacino' + | 'rifampicina' + | 'minociclina' + | 'dapsone'; + +export type MedicationIntolerance = 'dapsone' | 'rifampicin' | 'clofazimine'; + +/** Valor do select quando o paciente informa medicamento fora da lista padrão. */ +export const INSTITUTED_MEDICATION_OTHER_KEY = '__outro__'; + +export const INSTITUTED_MEDICATION_NAME_OPTIONS: readonly { value: string; label: string }[] = [ + { value: 'Prednisona', label: 'Prednisona' }, + { value: 'AINE', label: 'AINE' }, + { value: 'Talidomida', label: 'Talidomida' }, + { value: 'Pentoxifilina', label: 'Pentoxifilina' }, + { value: INSTITUTED_MEDICATION_OTHER_KEY, label: 'Outro' }, +] as const; + +export const INSTITUTED_MEDICATION_UNIT_OPTIONS = [ + 'mg', + 'mg/kg', + 'ml', + 'g', + 'comprimido', + 'gota', +] as const; + +export const INSTITUTED_MEDICATION_FREQUENCY_OPTIONS = [ + 'dia', + '12/12h', + '8/8h', + '6/6h', + 'semana', + 'quinzena', + 'mês', +] as const; + +export type InstitutedMedicationFormRow = { + medicationKey?: string; + customName?: string; + dose?: string; + unit?: string; + frequency?: string; +}; + +export function institutedMedicationSelectValue(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return ''; + const known = INSTITUTED_MEDICATION_NAME_OPTIONS.find( + (option) => + option.value !== INSTITUTED_MEDICATION_OTHER_KEY && + option.value.toLowerCase() === trimmed.toLowerCase() + ); + return known?.value ?? INSTITUTED_MEDICATION_OTHER_KEY; +} + +export function resolveInstitutedMedicationName(medicationKey: string, customName: string): string { + if (medicationKey === INSTITUTED_MEDICATION_OTHER_KEY) { + return customName.trim(); + } + return medicationKey.trim(); +} + +export function isInstitutedMedicationOtherKey(key: string): boolean { + return key === INSTITUTED_MEDICATION_OTHER_KEY; +} + +export function parseInstitutedMedicationRows( + controls: { getRawValue(): InstitutedMedicationFormRow }[] +): { name: string; dose: string; unit: string; frequency: string }[] { + return controls + .map((control) => { + const item = control.getRawValue(); + return { + name: resolveInstitutedMedicationName(item.medicationKey ?? '', item.customName ?? ''), + dose: (item.dose ?? '').trim(), + unit: (item.unit ?? 'mg').trim() || 'mg', + frequency: (item.frequency ?? 'dia').trim() || 'dia', + }; + }) + .filter((item) => item.name || item.dose); +} + +export type ReactionEpisodeType = + | '' + | 'tipo_1' + | 'tipo_2' + | 'mista_t1_t2' + | 'neurite_isolada' + | 'tipo_1_neurite' + | 'tipo_2_neurite' + | 'mista_t1_t2_neurite'; + +export interface PatientPersonalData { + fullName: string; + socialName: string; + cpf: string; + susCard: string; + birthDate: string; + maritalStatus: string; + nationality: string; + raceColor: string; + indigenousEthnicity: string; + sex: string; + wantsGenderIdentity: YesNoChoice; + genderIdentity: string; + genderIdentityOther: string; + wantsSexualOrientation: YesNoChoice; + sexualOrientation: string; + sexualOrientationOther: string; + address: string; + phone: string; + email: string; + education: string; + occupation: string; + healthUnit: string; + acsName: string; + nurseName: string; + doctorName: string; + emergencyContact: string; + bloodType: string; + medicationAllergies: string; +} + +/** Credenciais de acesso (login). Separado do e-mail de contato na caderneta. */ +export interface PatientAccountData { + loginEmail: string; + /** Apenas mock local até integração com API de autenticação. */ + password: string; +} + +export interface PatientTreatmentData { + currentDoseMedication: string; + diagnosisDate: string; + cnsNumber: string; + sinanNumber: string; + classification: LeprosyClassification; + treatmentStartDate: string; + clinicalForm: ClinicalForm; + baciloscopyDate: string; + baciloscopyIB: string; + diagnosticSupportExam: string; + gifAssessment: GifGrade; + reactionEpisodeAtDiagnosis: YesNoChoice; + reactionEpisodeType: ReactionEpisodeType; + reactionEpisodeDate: string; + prednisoneMgKg: string; + aineMgDay: string; + thalidomideMgDay: string; + pentoxifyllineMgDay: string; + otherMedication: string; + institutedMedications: { + name: string; + dose: string; + unit: string; + frequency: string; + }[]; + otherConducts: string; + substituteSchemeChangeDate: string; + intoleranceDapsone: boolean; + intoleranceRifampicin: boolean; + intoleranceClofazimine: boolean; + schemeClofazimina: boolean; + schemeOfloxacino: boolean; + schemeRifampicina: boolean; + schemeMinociclina: boolean; + schemeDapsone: boolean; + pqtDischargeDate: string; + gifAssessmentAtDischarge: GifGrade; + reactionEpisodeAtDischarge: YesNoChoice; + reactionEpisodeTypeAtDischarge: ReactionEpisodeType; + reactionEpisodeDateAtDischarge: string; + dischargePrednisoneMgKg: string; + dischargeAineMgDay: string; + dischargeThalidomideMgDay: string; + dischargePentoxifyllineMgDay: string; + dischargeOtherMedication: string; + dischargeOtherConducts: string; +} + +export interface PatientProfile { + avatarDataUrl: string; + account: PatientAccountData; + personal: PatientPersonalData; + treatment: PatientTreatmentData; +} + +export const EDUCATION_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'fundamental_incompleto', label: 'Ensino fundamental incompleto' }, + { value: 'fundamental_completo', label: 'Ensino fundamental completo' }, + { value: 'medio_incompleto', label: 'Ensino médio incompleto' }, + { value: 'medio_completo', label: 'Ensino médio completo' }, + { value: 'superior_incompleto', label: 'Superior incompleto' }, + { value: 'superior_completo', label: 'Superior completo' }, + { value: 'pos_graduacao', label: 'Pós-graduação' }, +] as const; + +export const BLOOD_TYPE_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Não informado' }, + { value: 'A+', label: 'A+' }, + { value: 'A-', label: 'A-' }, + { value: 'B+', label: 'B+' }, + { value: 'B-', label: 'B-' }, + { value: 'AB+', label: 'AB+' }, + { value: 'AB-', label: 'AB-' }, + { value: 'O+', label: 'O+' }, + { value: 'O-', label: 'O-' }, +] as const; + +export const MARITAL_STATUS_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'solteiro', label: 'Solteiro(a)' }, + { value: 'casado', label: 'Casado(a)' }, + { value: 'divorciado', label: 'Divorciado(a)' }, + { value: 'viuvo', label: 'Viúvo(a)' }, + { value: 'uniao_estavel', label: 'União estável' }, + { value: 'separado', label: 'Separado(a)' }, + { value: 'outro', label: 'Outro' }, +] as const; + +export const NATIONALITY_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'brasileiro', label: 'Brasileiro(a)' }, + { value: 'estrangeiro', label: 'Estrangeiro(a)' }, +] as const; + +export const RACE_COLOR_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'branca', label: 'Branca' }, + { value: 'preta', label: 'Preta' }, + { value: 'parda', label: 'Parda' }, + { value: 'amarela', label: 'Amarela' }, + { value: 'indigena', label: 'Indígena' }, +] as const; + +export const SEX_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'feminino', label: 'Feminino' }, + { value: 'masculino', label: 'Masculino' }, +] as const; + +export const YES_NO_OPTIONS: readonly { value: YesNoChoice; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'sim', label: 'Sim' }, + { value: 'nao', label: 'Não' }, +] as const; + +export const GENDER_IDENTITY_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'homem_transexual', label: 'Homem transexual' }, + { value: 'mulher_transexual', label: 'Mulher transexual' }, + { value: 'travesti', label: 'Travesti' }, + { value: 'outra', label: 'Outra' }, +] as const; + +export const SEXUAL_ORIENTATION_OPTIONS: readonly { value: string; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'heterossexual', label: 'Heterossexual' }, + { value: 'bissexual', label: 'Bissexual' }, + { value: 'homossexual', label: 'Homossexual (gay/lésbica)' }, + { value: 'outra', label: 'Outra' }, +] as const; + +export const INTOLERANCE_OPTIONS: readonly { key: MedicationIntolerance; label: string }[] = [ + { key: 'dapsone', label: 'Dapsona' }, + { key: 'rifampicin', label: 'Rifampicina' }, + { key: 'clofazimine', label: 'Clofazimina' }, +] as const; + +export const SUBSTITUTE_SCHEME_MEDICATION_OPTIONS: readonly { + key: SubstituteSchemeMedication; + label: string; +}[] = [ + { key: 'clofazimina', label: 'Clofazimina' }, + { key: 'ofloxacino', label: 'Ofloxacino' }, + { key: 'rifampicina', label: 'Rifampicina' }, + { key: 'minociclina', label: 'Minociclina' }, + { key: 'dapsone', label: 'Dapsona' }, +] as const; + +export const CLASSIFICATION_OPTIONS: readonly { value: LeprosyClassification; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'PB', label: 'PB — paucibacilar' }, + { value: 'MB', label: 'MB — multibacilar' }, +] as const; + +export const CLINICAL_FORM_OPTIONS: readonly { value: ClinicalForm; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'I', label: 'I — indeterminada' }, + { value: 'T', label: 'T — tuberculóide' }, + { value: 'D', label: 'D — dimorfa' }, + { value: 'V', label: 'V — virchowiana' }, +] as const; + +export const GIF_GRADE_OPTIONS: readonly { value: GifGrade; label: string }[] = [ + { value: '', label: 'Selecione' }, + { value: 'grau_0', label: 'Grau 0' }, + { value: 'grau_1', label: 'Grau 1' }, + { value: 'grau_2', label: 'Grau 2' }, +] as const; + +export const REACTION_EPISODE_TYPE_OPTIONS: readonly { + value: ReactionEpisodeType; + label: string; +}[] = [ + { value: '', label: 'Selecione' }, + { value: 'tipo_1', label: 'Tipo 1' }, + { value: 'tipo_2', label: 'Tipo 2' }, + { value: 'mista_t1_t2', label: 'Mista T1 + T2' }, + { value: 'neurite_isolada', label: 'Neurite isolada' }, + { value: 'tipo_1_neurite', label: 'Tipo 1 + neurite' }, + { value: 'tipo_2_neurite', label: 'Tipo 2 + neurite' }, + { value: 'mista_t1_t2_neurite', label: 'Mista T1 + T2 + neurite' }, +] as const; + +export const EMPTY_PERSONAL_DATA: PatientPersonalData = { + fullName: '', + socialName: '', + cpf: '', + susCard: '', + birthDate: '', + maritalStatus: '', + nationality: '', + raceColor: '', + indigenousEthnicity: '', + sex: '', + wantsGenderIdentity: '', + genderIdentity: '', + genderIdentityOther: '', + wantsSexualOrientation: '', + sexualOrientation: '', + sexualOrientationOther: '', + address: '', + phone: '', + email: '', + education: '', + occupation: '', + healthUnit: '', + acsName: '', + nurseName: '', + doctorName: '', + emergencyContact: '', + bloodType: '', + medicationAllergies: '', +}; + +export const EMPTY_TREATMENT_DATA: PatientTreatmentData = { + currentDoseMedication: '', + diagnosisDate: '', + cnsNumber: '', + sinanNumber: '', + classification: '', + treatmentStartDate: '', + clinicalForm: '', + baciloscopyDate: '', + baciloscopyIB: '', + diagnosticSupportExam: '', + gifAssessment: '', + reactionEpisodeAtDiagnosis: '', + reactionEpisodeType: '', + reactionEpisodeDate: '', + prednisoneMgKg: '', + aineMgDay: '', + thalidomideMgDay: '', + pentoxifyllineMgDay: '', + otherMedication: '', + institutedMedications: [], + otherConducts: '', + substituteSchemeChangeDate: '', + intoleranceDapsone: false, + intoleranceRifampicin: false, + intoleranceClofazimine: false, + schemeClofazimina: false, + schemeOfloxacino: false, + schemeRifampicina: false, + schemeMinociclina: false, + schemeDapsone: false, + pqtDischargeDate: '', + gifAssessmentAtDischarge: '', + reactionEpisodeAtDischarge: '', + reactionEpisodeTypeAtDischarge: '', + reactionEpisodeDateAtDischarge: '', + dischargePrednisoneMgKg: '', + dischargeAineMgDay: '', + dischargeThalidomideMgDay: '', + dischargePentoxifyllineMgDay: '', + dischargeOtherMedication: '', + dischargeOtherConducts: '', +}; + +export const EMPTY_ACCOUNT_DATA: PatientAccountData = { + loginEmail: '', + password: '', +}; + +export const EMPTY_PATIENT_PROFILE: PatientProfile = { + avatarDataUrl: '', + account: { ...EMPTY_ACCOUNT_DATA }, + personal: { ...EMPTY_PERSONAL_DATA }, + treatment: { ...EMPTY_TREATMENT_DATA }, +}; + +export const PERSONAL_FIELD_LABELS: Record = { + fullName: 'Nome completo', + socialName: 'Nome social', + cpf: 'CPF', + susCard: 'Cartão SUS', + birthDate: 'Data de nascimento', + maritalStatus: 'Estado civil', + nationality: 'Nacionalidade', + raceColor: 'Raça/cor', + indigenousEthnicity: 'Etnia indígena', + sex: 'Sexo', + wantsGenderIdentity: 'Informar identidade de gênero', + genderIdentity: 'Identidade de gênero', + genderIdentityOther: 'Identidade de gênero (outra)', + wantsSexualOrientation: 'Informar orientação sexual', + sexualOrientation: 'Orientação sexual', + sexualOrientationOther: 'Orientação sexual (outra)', + address: 'Endereço', + phone: 'Telefone', + email: 'E-mail', + education: 'Escolaridade', + occupation: 'Ocupação', + healthUnit: 'Unidade de saúde frequentada', + acsName: 'ACS', + nurseName: 'Enfermeiro(a) responsável', + doctorName: 'Médico(a) responsável', + emergencyContact: 'Contato de emergência', + bloodType: 'Tipo sanguíneo', + medicationAllergies: 'Alergia a medicamentos', +}; + +export const TREATMENT_FIELD_LABELS: Record = { + currentDoseMedication: 'Medicamento atual da dose', + diagnosisDate: 'Data do diagnóstico', + cnsNumber: 'Número do CNS', + sinanNumber: 'Número do Sinan', + classification: 'Classificação PB ou MB', + treatmentStartDate: 'Início do tratamento', + clinicalForm: 'Forma clínica', + baciloscopyDate: 'Baciloscopia — data', + baciloscopyIB: 'Baciloscopia — IB', + diagnosticSupportExam: 'Outro exame de apoio diagnóstico', + gifAssessment: 'Avaliação GIF', + reactionEpisodeAtDiagnosis: 'Episódio reacional por ocasião do diagnóstico', + reactionEpisodeType: 'Episódio reacional — tipo', + reactionEpisodeDate: 'Episódio reacional — data', + prednisoneMgKg: 'Prednisona (mg/kg)', + aineMgDay: 'AINE (mg/dia)', + thalidomideMgDay: 'Talidomida (mg/dia)', + pentoxifyllineMgDay: 'Pentoxifilina (mg/dia)', + otherMedication: 'Outro medicamento', + institutedMedications: 'Medicamentos instituídos', + otherConducts: 'Outras condutas', + substituteSchemeChangeDate: 'Esquema substitutivo — data da mudança', + intoleranceDapsone: 'Intolerância — Dapsona', + intoleranceRifampicin: 'Intolerância — Rifampicina', + intoleranceClofazimine: 'Intolerância — Clofazimina', + schemeClofazimina: 'Esquema — Clofazimina', + schemeOfloxacino: 'Esquema — Ofloxacino', + schemeRifampicina: 'Esquema — Rifampicina', + schemeMinociclina: 'Esquema — Minociclina', + schemeDapsone: 'Esquema — Dapsona', + pqtDischargeDate: 'Alta do tratamento — data', + gifAssessmentAtDischarge: 'Classificação do GIF na alta do tratamento', + reactionEpisodeAtDischarge: 'Episódio reacional por ocasião da alta', + reactionEpisodeTypeAtDischarge: 'Episódio reacional na alta — tipo', + reactionEpisodeDateAtDischarge: 'Episódio reacional na alta — data', + dischargePrednisoneMgKg: 'Prednisona na alta (mg/kg)', + dischargeAineMgDay: 'AINE na alta (mg/dia)', + dischargeThalidomideMgDay: 'Talidomida na alta (mg/dia)', + dischargePentoxifyllineMgDay: 'Pentoxifilina na alta (mg/dia)', + dischargeOtherMedication: 'Outro medicamento na alta', + dischargeOtherConducts: 'Outras condutas na alta', +}; diff --git a/frontend/src/app/features/profile/profile.html b/frontend/src/app/features/profile/profile.html index 84d31d3..c0bdd4e 100644 --- a/frontend/src/app/features/profile/profile.html +++ b/frontend/src/app/features/profile/profile.html @@ -1,11 +1,985 @@ -
-

+

Meu perfil

+ + @if (accountSavedToast()) { +

+ E-mail e senha atualizados. +

+ } + @if (avatarRemovedToast()) { +

+ Foto de perfil removida. +

+ } + @if (personalSavedToast()) { +

+ Dados pessoais salvos. +

+ } + @if (treatmentSavedToast()) { +

+ Dados de tratamento salvos. +

+ } + @if (exportPdfHint()) { +

+ A exportação em PDF estará disponível em breve pelo servidor. +

+ } + +
- Perfil - -

- Em breve você poderá editar seus dados e preferências aqui. -

+
+ @if (profile().avatarDataUrl) { + + } @else { + + } + +
+ + + @if (showAvatarMenu()) { + + } +
+ + +
+ +

+ {{ displayName() }} +

+

+ Nome exibido nas suas interações caso não use o modo anônimo. +

+ @if (profile().personal.fullName && profile().personal.socialName) { +

+ Nome completo na caderneta: {{ profile().personal.fullName }} +

+ } +
+ +
+ + +
+ + @if (activeTab() === 'overview') { +
+
+
+
+

Acesso à conta

+

+ E-mail e senha usados para entrar no Pequi. +

+
+ +
+
+
+
+ E-mail de login +
+
+ {{ maskedLoginEmail() }} +
+
+
+
Senha
+
+ @if (hasAccountPassword()) { + •••••••• + } @else { + Ainda não definida + } +
+
+
+
+ +
+
+
+

Dados pessoais

+

+ Informações da sua caderneta de acompanhamento. +

+
+ +
+ + @if (hasPersonalData()) { +
+ @if (profile().personal.phone) { +
+
+ Telefone +
+
{{ profile().personal.phone }}
+
+ } + @if (profile().personal.email) { +
+
+ E-mail de contato +
+
{{ profile().personal.email }}
+
+ } + @if (profile().personal.healthUnit) { +
+
+ Unidade de saúde +
+
{{ profile().personal.healthUnit }}
+
+ } + @if (profile().personal.bloodType) { +
+
+ Tipo sanguíneo +
+
+ {{ bloodTypeLabel(profile().personal.bloodType) }} +
+
+ } +
+

+ Toque em Editar para ver e atualizar todos os campos do cadastro. +

+ } @else { +
+ +

+ Você ainda não preencheu seus dados pessoais. Toque em Editar para começar. +

+
+ } +
+ + +

+ Esta é uma cópia das informações que é possível encontrar na + CADERNETA DE SAÚDE DA PESSOA ACOMETIDA PELA HANSENÍASE + disponibilizada pelo Ministério da Saúde. +

+
+ } + + @if (activeTab() === 'treatment') { +
+

+ Com esses registros você terá acesso à história de seu tratamento sempre que necessário, para + acompanhamento do seu estado de saúde. +

+ +
+ Preencha conforme orientação ou registro do profissional de saúde. +
+ +
+
+ Medicamento atual da dose +

+ Marque os medicamentos da dose mensal. Este registro aparece no fluxo de Registrar consulta. +

+
+ @for (opt of substituteSchemeMedicationOptions; track opt.key) { + + } +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ Baciloscopia +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (hasReactionEpisodeAtDiagnosis() === 'sim') { +
+
+ + +
+
+ + +
+
+ } + +
+ + Medicamentos instituídos + +
+ @if (institutedMedicationsArray.length === 0) { +

Nenhum medicamento instituído. Use o botão abaixo para adicionar.

+ } + @for (medControl of institutedMedicationsArray.controls; track $index) { +
+
+
+ + + @if (isInstitutedMedicationOther($index)) { + + } +
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+ } +
+ +
+ +
+ + +
+ +
+ Esquema substitutivo + +
+ + +
+ +
+ Intolerância +
+ @for (opt of intoleranceOptions; track opt.key) { + + } +
+
+ +
+ Esquema medicamentoso +
+ @for (opt of substituteSchemeMedicationOptions; track opt.key) { + + } +
+
+
+ +
+

+ Alta do tratamento da PQT/esquema substitutivo +

+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+ + @if (hasReactionEpisodeAtDischarge() === 'sim') { +
+
+ + +
+
+ + +
+ +
+ + Medicamentos instituídos + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ } +
+ + +
+
+ }
+ +@if (showEditPersonal()) { + +} + +@if (showEditAccount()) { + +} diff --git a/frontend/src/app/features/profile/profile.spec.ts b/frontend/src/app/features/profile/profile.spec.ts new file mode 100644 index 0000000..143f7d9 --- /dev/null +++ b/frontend/src/app/features/profile/profile.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { EMPTY_PERSONAL_DATA } from './models/patient-profile.models'; +import { Profile } from './profile'; +import { PatientProfileService } from './services/patient-profile.service'; + +describe('Profile', () => { + let fixture: ComponentFixture; + let component: Profile; + let profileService: PatientProfileService; + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [Profile], + providers: [provideRouter([])], + }).compileComponents(); + + profileService = TestBed.inject(PatientProfileService); + fixture = TestBed.createComponent(Profile); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show default display name', () => { + const nameEl = fixture.nativeElement.querySelector('[data-testid="profile-display-name"]'); + expect(nameEl?.textContent?.trim()).toBe('Paciente'); + }); + + it('should show display name from personal data after save', () => { + component.onPersonalSaved({ + ...EMPTY_PERSONAL_DATA, + fullName: 'Ana Costa', + socialName: 'Ana', + }); + fixture.detectChanges(); + expect(component.displayName()).toBe('Ana'); + }); + + it('should not show edit display name button', () => { + expect(fixture.nativeElement.querySelector('[data-testid="edit-display-name-button"]')).toBeFalsy(); + }); + + it('should open edit personal dialog', () => { + fixture.nativeElement.querySelector('[data-testid="edit-personal-button"]')?.click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="edit-personal-dialog"]')).toBeTruthy(); + }); + + it('should open edit account dialog', () => { + fixture.nativeElement.querySelector('[data-testid="edit-account-button"]')?.click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="edit-account-dialog"]')).toBeTruthy(); + }); + + it('should remove avatar from avatar menu', () => { + profileService.updateAvatar('data:image/png;base64,x'); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-testid="avatar-menu-toggle"]')?.click(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('[data-testid="remove-avatar-option"]')?.click(); + fixture.detectChanges(); + + expect(profileService.hasAvatar()).toBe(false); + }); + + it('should show export pdf disclaimer', () => { + expect(fixture.nativeElement.querySelector('[data-testid="export-pdf-disclaimer"]')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/profile/profile.ts b/frontend/src/app/features/profile/profile.ts index ed3227c..29e21d6 100644 --- a/frontend/src/app/features/profile/profile.ts +++ b/frontend/src/app/features/profile/profile.ts @@ -1,9 +1,483 @@ -import { Component } from '@angular/core'; +import { Component, inject, signal, type WritableSignal } from '@angular/core'; +import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { + LucideAngularModule, + LucideDownload, + LucideKeyRound, + LucidePencil, + LucideUser, +} from 'lucide-angular'; +import type { AccountSavePayload } from './components/profile-edit-account/profile-edit-account'; +import { ProfileEditAccount } from './components/profile-edit-account/profile-edit-account'; +import { ProfileEditPersonal } from './components/profile-edit-personal/profile-edit-personal'; +import { + CLASSIFICATION_OPTIONS, + BLOOD_TYPE_OPTIONS, + CLINICAL_FORM_OPTIONS, + GIF_GRADE_OPTIONS, + INTOLERANCE_OPTIONS, + REACTION_EPISODE_TYPE_OPTIONS, + INSTITUTED_MEDICATION_FREQUENCY_OPTIONS, + INSTITUTED_MEDICATION_NAME_OPTIONS, + INSTITUTED_MEDICATION_OTHER_KEY, + INSTITUTED_MEDICATION_UNIT_OPTIONS, + SUBSTITUTE_SCHEME_MEDICATION_OPTIONS, + YES_NO_OPTIONS, + isInstitutedMedicationOtherKey, + institutedMedicationSelectValue, + parseInstitutedMedicationRows, + type PatientPersonalData, + type PatientTreatmentData, + type YesNoChoice, +} from './models/patient-profile.models'; +import { + PatientProfileService, + type ChangePasswordError, +} from './services/patient-profile.service'; +import { PatientMedicationService } from '../appointments/services/patient-medication.service'; + +export type ProfileTab = 'overview' | 'treatment'; @Component({ selector: 'app-profile', standalone: true, - imports: [], + imports: [ReactiveFormsModule, LucideAngularModule, ProfileEditPersonal, ProfileEditAccount], templateUrl: './profile.html', }) -export class Profile {} +export class Profile { + readonly institutedMedicationNameOptions = INSTITUTED_MEDICATION_NAME_OPTIONS; + readonly institutedMedicationUnitOptions = INSTITUTED_MEDICATION_UNIT_OPTIONS; + readonly institutedMedicationFrequencyOptions = INSTITUTED_MEDICATION_FREQUENCY_OPTIONS; + + private readonly fb = inject(FormBuilder); + private readonly profileService = inject(PatientProfileService); + private readonly medicationService = inject(PatientMedicationService); + + readonly LucideDownload = LucideDownload; + readonly LucideKeyRound = LucideKeyRound; + readonly LucidePencil = LucidePencil; + readonly LucideUser = LucideUser; + + readonly classificationOptions = CLASSIFICATION_OPTIONS; + readonly clinicalFormOptions = CLINICAL_FORM_OPTIONS; + readonly gifGradeOptions = GIF_GRADE_OPTIONS; + readonly yesNoOptions = YES_NO_OPTIONS; + readonly reactionEpisodeTypeOptions = REACTION_EPISODE_TYPE_OPTIONS; + readonly intoleranceOptions = INTOLERANCE_OPTIONS; + readonly substituteSchemeMedicationOptions = SUBSTITUTE_SCHEME_MEDICATION_OPTIONS; + readonly bloodTypeOptions = BLOOD_TYPE_OPTIONS; + + readonly profile = this.profileService.profile; + readonly displayName = this.profileService.displayName; + readonly initials = this.profileService.initials; + readonly hasAvatar = this.profileService.hasAvatar; + readonly hasPersonalData = this.profileService.hasPersonalData; + + readonly activeTab = signal('overview'); + readonly showEditPersonal = signal(false); + readonly showEditAccount = signal(false); + readonly showAvatarMenu = signal(false); + readonly accountPasswordError = signal(null); + + readonly personalSavedToast = signal(false); + readonly treatmentSavedToast = signal(false); + readonly accountSavedToast = signal(false); + readonly avatarRemovedToast = signal(false); + readonly exportPdfHint = signal(false); + readonly hasReactionEpisodeAtDiagnosis = signal(''); + readonly hasReactionEpisodeAtDischarge = signal(''); + + readonly treatmentForm = this.fb.group({ + currentDoseMedication: [''], + diagnosisDate: [''], + cnsNumber: [''], + sinanNumber: [''], + classification: [''], + treatmentStartDate: [''], + clinicalForm: [''], + baciloscopyDate: [''], + baciloscopyIB: [''], + diagnosticSupportExam: [''], + gifAssessment: [''], + reactionEpisodeAtDiagnosis: ['' as YesNoChoice], + reactionEpisodeType: [''], + reactionEpisodeDate: [''], + prednisoneMgKg: [''], + aineMgDay: [''], + thalidomideMgDay: [''], + pentoxifyllineMgDay: [''], + otherMedication: [''], + institutedMedications: this.fb.array([]), + otherConducts: [''], + substituteSchemeChangeDate: [''], + intoleranceDapsone: [false], + intoleranceRifampicin: [false], + intoleranceClofazimine: [false], + schemeClofazimina: [false], + schemeOfloxacino: [false], + schemeRifampicina: [false], + schemeMinociclina: [false], + schemeDapsone: [false], + pqtDischargeDate: [''], + gifAssessmentAtDischarge: [''], + reactionEpisodeAtDischarge: ['' as YesNoChoice], + reactionEpisodeTypeAtDischarge: [''], + reactionEpisodeDateAtDischarge: [''], + dischargePrednisoneMgKg: [''], + dischargeAineMgDay: [''], + dischargeThalidomideMgDay: [''], + dischargePentoxifyllineMgDay: [''], + dischargeOtherMedication: [''], + dischargeOtherConducts: [''], + }); + + get institutedMedicationsArray(): FormArray { + return this.treatmentForm.controls.institutedMedications as FormArray; + } + + addInstitutedMedication(name = '', dose = '', unit = 'mg', frequency = 'dia'): void { + const medicationKey = institutedMedicationSelectValue(name); + this.institutedMedicationsArray.push( + this.fb.group({ + medicationKey: [medicationKey], + customName: [medicationKey === INSTITUTED_MEDICATION_OTHER_KEY ? name : ''], + dose: [dose], + unit: [unit], + frequency: [frequency], + }) + ); + } + + removeInstitutedMedication(index: number): void { + this.institutedMedicationsArray.removeAt(index); + } + + isInstitutedMedicationOther(index: number): boolean { + const key = this.institutedMedicationsArray.at(index)?.get('medicationKey')?.value; + return isInstitutedMedicationOtherKey(String(key ?? '')); + } + + constructor() { + this.treatmentForm.controls.reactionEpisodeAtDiagnosis.valueChanges.subscribe((value) => { + this.hasReactionEpisodeAtDiagnosis.set((value ?? '') as YesNoChoice); + if (value !== 'sim') { + this.treatmentForm.patchValue( + { reactionEpisodeType: '', reactionEpisodeDate: '' }, + { emitEvent: false } + ); + } + }); + this.treatmentForm.controls.reactionEpisodeAtDischarge.valueChanges.subscribe((value) => { + this.hasReactionEpisodeAtDischarge.set((value ?? '') as YesNoChoice); + if (value !== 'sim') { + this.treatmentForm.patchValue( + { + reactionEpisodeTypeAtDischarge: '', + reactionEpisodeDateAtDischarge: '', + dischargePrednisoneMgKg: '', + dischargeAineMgDay: '', + dischargeThalidomideMgDay: '', + dischargePentoxifyllineMgDay: '', + dischargeOtherMedication: '', + dischargeOtherConducts: '', + }, + { emitEvent: false } + ); + } + }); + this.treatmentForm.controls.intoleranceDapsone.valueChanges.subscribe((value) => { + if (value) { + this.treatmentForm.patchValue({ schemeDapsone: false }, { emitEvent: false }); + this.updateCurrentDoseFromScheme(); + } + }); + this.treatmentForm.controls.intoleranceRifampicin.valueChanges.subscribe((value) => { + if (value) { + this.treatmentForm.patchValue({ schemeRifampicina: false }, { emitEvent: false }); + this.updateCurrentDoseFromScheme(); + } + }); + this.treatmentForm.controls.intoleranceClofazimine.valueChanges.subscribe((value) => { + if (value) { + this.treatmentForm.patchValue({ schemeClofazimina: false }, { emitEvent: false }); + this.updateCurrentDoseFromScheme(); + } + }); + this.treatmentForm.controls.schemeClofazimina.valueChanges.subscribe(() => + this.updateCurrentDoseFromScheme() + ); + this.treatmentForm.controls.schemeOfloxacino.valueChanges.subscribe(() => + this.updateCurrentDoseFromScheme() + ); + this.treatmentForm.controls.schemeRifampicina.valueChanges.subscribe(() => + this.updateCurrentDoseFromScheme() + ); + this.treatmentForm.controls.schemeMinociclina.valueChanges.subscribe(() => + this.updateCurrentDoseFromScheme() + ); + this.treatmentForm.controls.schemeDapsone.valueChanges.subscribe(() => + this.updateCurrentDoseFromScheme() + ); + this.syncTreatmentForm(this.profileService.profile().treatment); + } + + setTab(tab: ProfileTab): void { + this.activeTab.set(tab); + if (tab === 'treatment') { + this.syncTreatmentForm(this.profile().treatment); + } + } + + openEditPersonal(): void { + this.showEditPersonal.set(true); + } + + closeEditPersonal(): void { + this.showEditPersonal.set(false); + } + + openEditAccount(): void { + this.accountPasswordError.set(null); + this.showEditAccount.set(true); + } + + closeEditAccount(): void { + this.showEditAccount.set(false); + this.accountPasswordError.set(null); + } + + onPersonalSaved(data: PatientPersonalData): void { + this.profileService.updatePersonal(data); + this.showEditPersonal.set(false); + this.showToast(this.personalSavedToast); + } + + onAccountSaved(payload: AccountSavePayload): void { + this.profileService.updateLoginEmail(payload.loginEmail); + + const changingPassword = + payload.newPassword.length > 0 || payload.confirmPassword.length > 0; + + if (changingPassword) { + const result = this.profileService.changePassword( + payload.currentPassword, + payload.newPassword, + payload.confirmPassword + ); + if (!result.ok) { + this.accountPasswordError.set(result.error); + return; + } + } + + this.accountPasswordError.set(null); + this.showEditAccount.set(false); + this.showToast(this.accountSavedToast); + } + + toggleAvatarMenu(): void { + this.showAvatarMenu.update((open) => !open); + } + + closeAvatarMenu(): void { + this.showAvatarMenu.set(false); + } + + pickAvatar(fileInput: HTMLInputElement): void { + this.closeAvatarMenu(); + fileInput.click(); + } + + onAvatarSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file || !file.type.startsWith('image/')) return; + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result === 'string') { + this.profileService.updateAvatar(result); + } + }; + reader.readAsDataURL(file); + input.value = ''; + this.closeAvatarMenu(); + } + + removeAvatar(): void { + if (!this.hasAvatar()) return; + this.profileService.removeAvatar(); + this.closeAvatarMenu(); + this.showToast(this.avatarRemovedToast); + } + + saveTreatment(): void { + const raw = this.treatmentForm.getRawValue(); + const institutedMedications = parseInstitutedMedicationRows( + this.institutedMedicationsArray.controls + ); + const byName = (name: string) => + institutedMedications.find((item) => item.name.toLowerCase() === name.toLowerCase())?.dose ?? ''; + const customMeds = institutedMedications + .filter( + (item) => + !['prednisona', 'aine', 'talidomida', 'pentoxifilina'].includes(item.name.toLowerCase()) + ) + .map((item) => `${item.name} ${item.dose} ${item.unit}/${item.frequency}`.trim()) + .join(' | '); + + const treatment: PatientTreatmentData = { + currentDoseMedication: raw.currentDoseMedication ?? '', + diagnosisDate: raw.diagnosisDate ?? '', + cnsNumber: raw.cnsNumber ?? '', + sinanNumber: raw.sinanNumber ?? '', + classification: (raw.classification as PatientTreatmentData['classification']) ?? '', + treatmentStartDate: raw.treatmentStartDate ?? '', + clinicalForm: (raw.clinicalForm as PatientTreatmentData['clinicalForm']) ?? '', + baciloscopyDate: raw.baciloscopyDate ?? '', + baciloscopyIB: raw.baciloscopyIB ?? '', + diagnosticSupportExam: raw.diagnosticSupportExam ?? '', + gifAssessment: (raw.gifAssessment as PatientTreatmentData['gifAssessment']) ?? '', + reactionEpisodeAtDiagnosis: + (raw.reactionEpisodeAtDiagnosis as PatientTreatmentData['reactionEpisodeAtDiagnosis']) ?? + '', + reactionEpisodeType: + raw.reactionEpisodeAtDiagnosis === 'sim' + ? ((raw.reactionEpisodeType as PatientTreatmentData['reactionEpisodeType']) ?? '') + : '', + reactionEpisodeDate: + raw.reactionEpisodeAtDiagnosis === 'sim' ? (raw.reactionEpisodeDate ?? '') : '', + prednisoneMgKg: byName('Prednisona'), + aineMgDay: byName('AINE'), + thalidomideMgDay: byName('Talidomida'), + pentoxifyllineMgDay: byName('Pentoxifilina'), + otherMedication: customMeds, + institutedMedications, + otherConducts: raw.otherConducts ?? '', + substituteSchemeChangeDate: raw.substituteSchemeChangeDate ?? '', + intoleranceDapsone: raw.intoleranceDapsone ?? false, + intoleranceRifampicin: raw.intoleranceRifampicin ?? false, + intoleranceClofazimine: raw.intoleranceClofazimine ?? false, + schemeClofazimina: raw.schemeClofazimina ?? false, + schemeOfloxacino: raw.schemeOfloxacino ?? false, + schemeRifampicina: raw.schemeRifampicina ?? false, + schemeMinociclina: raw.schemeMinociclina ?? false, + schemeDapsone: raw.schemeDapsone ?? false, + pqtDischargeDate: raw.pqtDischargeDate ?? '', + gifAssessmentAtDischarge: + (raw.gifAssessmentAtDischarge as PatientTreatmentData['gifAssessmentAtDischarge']) ?? '', + reactionEpisodeAtDischarge: + (raw.reactionEpisodeAtDischarge as PatientTreatmentData['reactionEpisodeAtDischarge']) ?? + '', + reactionEpisodeTypeAtDischarge: + raw.reactionEpisodeAtDischarge === 'sim' + ? ((raw.reactionEpisodeTypeAtDischarge as PatientTreatmentData['reactionEpisodeTypeAtDischarge']) ?? + '') + : '', + reactionEpisodeDateAtDischarge: + raw.reactionEpisodeAtDischarge === 'sim' + ? (raw.reactionEpisodeDateAtDischarge ?? '') + : '', + dischargePrednisoneMgKg: + raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargePrednisoneMgKg ?? '') : '', + dischargeAineMgDay: + raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargeAineMgDay ?? '') : '', + dischargeThalidomideMgDay: + raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargeThalidomideMgDay ?? '') : '', + dischargePentoxifyllineMgDay: + raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargePentoxifyllineMgDay ?? '') : '', + dischargeOtherMedication: + raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargeOtherMedication ?? '') : '', + dischargeOtherConducts: + raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargeOtherConducts ?? '') : '', + }; + this.profileService.updateTreatment(treatment); + this.medicationService.setCurrentDoseMedication(treatment.currentDoseMedication); + this.showToast(this.treatmentSavedToast); + } + + onExportPdf(): void { + this.exportPdfHint.set(true); + setTimeout(() => this.exportPdfHint.set(false), 4000); + } + + hasAccountPassword(): boolean { + return this.profile().account.password.length > 0; + } + + maskedLoginEmail(): string { + const email = this.profile().account.loginEmail.trim(); + return email || 'Não definido'; + } + + bloodTypeLabel(value: string): string { + return BLOOD_TYPE_OPTIONS.find((o) => o.value === value)?.label ?? '—'; + } + + private syncTreatmentForm(treatment: PatientTreatmentData): void { + const currentDose = + treatment.currentDoseMedication.trim() || + this.medicationService.getCurrentDoseMedication()?.name || + ''; + this.treatmentForm.patchValue( + { ...treatment, currentDoseMedication: currentDose }, + { emitEvent: false } + ); + this.institutedMedicationsArray.clear(); + const stored = treatment.institutedMedications ?? []; + if (stored.length > 0) { + for (const item of stored) { + this.addInstitutedMedication(item.name, item.dose, item.unit || 'mg', item.frequency || 'dia'); + } + return; + } + if (treatment.prednisoneMgKg.trim()) { + this.addInstitutedMedication('Prednisona', treatment.prednisoneMgKg, 'mg/kg', 'dia'); + } + if (treatment.aineMgDay.trim()) { + this.addInstitutedMedication('AINE', treatment.aineMgDay, 'mg', 'dia'); + } + if (treatment.thalidomideMgDay.trim()) { + this.addInstitutedMedication('Talidomida', treatment.thalidomideMgDay, 'mg', 'dia'); + } + if (treatment.pentoxifyllineMgDay.trim()) { + this.addInstitutedMedication('Pentoxifilina', treatment.pentoxifyllineMgDay, 'mg', 'dia'); + } + if (treatment.otherMedication.trim()) { + this.addInstitutedMedication(treatment.otherMedication, '', 'mg', 'dia'); + } + this.hasReactionEpisodeAtDiagnosis.set(treatment.reactionEpisodeAtDiagnosis); + this.hasReactionEpisodeAtDischarge.set(treatment.reactionEpisodeAtDischarge); + } + + private showToast(toast: WritableSignal): void { + toast.set(true); + setTimeout(() => toast.set(false), 3000); + } + + private updateCurrentDoseFromScheme(): void { + const controls = this.treatmentForm.controls; + const parts: string[] = []; + if (controls.schemeRifampicina.value) { + parts.push('Rifampicina'); + } + if (controls.schemeClofazimina.value) { + parts.push('Clofazimina'); + } + if (controls.schemeMinociclina.value) { + parts.push('Minociclina'); + } + if (controls.schemeOfloxacino.value) { + parts.push('Ofloxacino'); + } + if (controls.schemeDapsone.value) { + parts.push('Dapsona'); + } + const value = parts.join(' + '); + if (!value) { + return; + } + controls.currentDoseMedication.patchValue(value, { emitEvent: false }); + } +} diff --git a/frontend/src/app/features/profile/services/patient-profile.service.spec.ts b/frontend/src/app/features/profile/services/patient-profile.service.spec.ts new file mode 100644 index 0000000..bbd1037 --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-profile.service.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { EMPTY_PERSONAL_DATA, EMPTY_TREATMENT_DATA } from '../models/patient-profile.models'; +import { PatientProfileService } from './patient-profile.service'; + +describe('PatientProfileService', () => { + let service: PatientProfileService; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({}); + service = TestBed.inject(PatientProfileService); + }); + + it('should default display name to Paciente', () => { + expect(service.displayName()).toBe('Paciente'); + expect(service.initials()).toBe('PA'); + }); + + it('should prefer social name over full name', () => { + service.updatePersonal({ + ...EMPTY_PERSONAL_DATA, + fullName: 'Maria Silva', + socialName: 'Mari', + }); + expect(service.displayName()).toBe('Mari'); + expect(service.initials()).toBe('MA'); + }); + + it('should use full name when social name is empty', () => { + service.updatePersonal({ + ...EMPTY_PERSONAL_DATA, + fullName: 'João Souza', + }); + expect(service.displayName()).toBe('João Souza'); + }); + + it('should persist personal data to localStorage', () => { + service.updatePersonal({ + ...EMPTY_PERSONAL_DATA, + fullName: 'João Souza', + cpf: '123.456.789-00', + }); + + const raw = localStorage.getItem('pequi.patient_profile'); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw!) as { personal: { fullName: string; cpf: string } }; + expect(parsed.personal.fullName).toBe('João Souza'); + expect(parsed.personal.cpf).toBe('123.456.789-00'); + }); + + it('should persist treatment data', () => { + service.updateTreatment({ + ...EMPTY_TREATMENT_DATA, + classification: 'MB', + sinanNumber: '2026001234', + }); + expect(service.profile().treatment.classification).toBe('MB'); + expect(service.profile().treatment.sinanNumber).toBe('2026001234'); + }); + + it('should update and remove avatar', () => { + service.updateAvatar('data:image/png;base64,abc'); + expect(service.hasAvatar()).toBe(true); + service.removeAvatar(); + expect(service.hasAvatar()).toBe(false); + expect(service.profile().avatarDataUrl).toBe(''); + }); + + it('should update login email', () => { + service.updateLoginEmail('paciente@email.com'); + expect(service.profile().account.loginEmail).toBe('paciente@email.com'); + }); + + it('should change password when none set', () => { + const result = service.changePassword('', 'senha123', 'senha123'); + expect(result.ok).toBe(true); + expect(service.profile().account.password).toBe('senha123'); + }); + + it('should reject wrong current password', () => { + service.changePassword('', 'senha123', 'senha123'); + const result = service.changePassword('errada', 'nova123', 'nova123'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('wrong_current'); + }); +}); diff --git a/frontend/src/app/features/profile/services/patient-profile.service.ts b/frontend/src/app/features/profile/services/patient-profile.service.ts new file mode 100644 index 0000000..44c9fc2 --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-profile.service.ts @@ -0,0 +1,168 @@ +import { computed, Injectable, signal } from '@angular/core'; +import { + EMPTY_PATIENT_PROFILE, + type PatientAccountData, + type PatientPersonalData, + type PatientProfile, + type PatientTreatmentData, +} from '../models/patient-profile.models'; + +const STORAGE_KEY = 'pequi.patient_profile'; + +export type ChangePasswordError = 'wrong_current' | 'mismatch' | 'too_short'; + +export type ChangePasswordResult = + | { ok: true } + | { ok: false; error: ChangePasswordError }; + +@Injectable({ providedIn: 'root' }) +export class PatientProfileService { + private readonly profileSignal = signal(this.loadFromStorage()); + + readonly profile = this.profileSignal.asReadonly(); + + readonly displayName = computed(() => { + const { personal } = this.profileSignal(); + const social = personal.socialName.trim(); + const full = personal.fullName.trim(); + if (social) return social; + if (full) return full; + return 'Paciente'; + }); + + readonly initials = computed(() => { + const name = this.displayName(); + const parts = name.split(/\s+/).filter(Boolean); + if (parts.length === 0) return 'P'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + }); + + readonly hasAvatar = computed(() => this.profileSignal().avatarDataUrl.trim() !== ''); + + readonly hasPersonalData = computed(() => { + const p = this.profileSignal().personal; + return Object.values(p).some((v) => String(v).trim() !== ''); + }); + + updateAvatar(avatarDataUrl: string): void { + this.patch({ avatarDataUrl }); + } + + removeAvatar(): void { + this.patch({ avatarDataUrl: '' }); + } + + updateAccount(account: PatientAccountData): void { + this.patch({ + account: { + loginEmail: account.loginEmail.trim(), + password: account.password, + }, + }); + } + + updateLoginEmail(loginEmail: string): void { + const account = this.profileSignal().account; + this.updateAccount({ ...account, loginEmail: loginEmail.trim() }); + } + + changePassword( + currentPassword: string, + newPassword: string, + confirmPassword: string + ): ChangePasswordResult { + if (newPassword.length < 6) { + return { ok: false, error: 'too_short' }; + } + if (newPassword !== confirmPassword) { + return { ok: false, error: 'mismatch' }; + } + + const stored = this.profileSignal().account.password; + if (stored && stored !== currentPassword) { + return { ok: false, error: 'wrong_current' }; + } + + const account = this.profileSignal().account; + this.updateAccount({ ...account, password: newPassword }); + return { ok: true }; + } + + updatePersonal(personal: PatientPersonalData): void { + this.patch({ personal: { ...personal } }); + } + + updateTreatment(treatment: PatientTreatmentData): void { + this.patch({ treatment: { ...treatment } }); + } + + reset(): void { + this.profileSignal.set(structuredClone(EMPTY_PATIENT_PROFILE)); + this.persist(this.profileSignal()); + } + + private patch(partial: Partial): void { + const current = this.profileSignal(); + const next: PatientProfile = { + ...current, + ...partial, + account: partial.account ?? current.account, + personal: partial.personal ?? current.personal, + treatment: partial.treatment ?? current.treatment, + }; + this.profileSignal.set(next); + this.persist(next); + } + + private loadFromStorage(): PatientProfile { + if (typeof localStorage === 'undefined') { + return structuredClone(EMPTY_PATIENT_PROFILE); + } + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return structuredClone(EMPTY_PATIENT_PROFILE); + const parsed = JSON.parse(raw) as Partial; + return this.mergeWithDefaults(parsed); + } catch { + return structuredClone(EMPTY_PATIENT_PROFILE); + } + } + + private mergeWithDefaults(parsed: Partial): PatientProfile { + const base = structuredClone(EMPTY_PATIENT_PROFILE); + const legacyTreatment = parsed.treatment as + | (Partial & { + baciloscopy?: string; + institutedMedications?: string; + }) + | undefined; + + const treatment: PatientTreatmentData = { + ...base.treatment, + ...legacyTreatment, + baciloscopyIB: + legacyTreatment?.baciloscopyIB ?? + (legacyTreatment?.baciloscopy && !legacyTreatment?.baciloscopyDate + ? legacyTreatment.baciloscopy + : base.treatment.baciloscopyIB), + otherMedication: + legacyTreatment?.otherMedication ?? + (legacyTreatment?.institutedMedications && !legacyTreatment?.prednisoneMgKg + ? legacyTreatment.institutedMedications + : base.treatment.otherMedication), + }; + + return { + avatarDataUrl: parsed.avatarDataUrl ?? base.avatarDataUrl, + account: { ...base.account, ...parsed.account }, + personal: { ...base.personal, ...parsed.personal }, + treatment, + }; + } + + private persist(profile: PatientProfile): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(profile)); + } +} From cdb872c36ab92165644fc8d87b6942b5fc9f4fab Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Wed, 27 May 2026 22:31:23 -0300 Subject: [PATCH 22/69] PEQ-82: Implement M7 educational articles library (#30) * feat(articles): implement M7 educational articles library (PEQ-82) Co-authored-by: Cursor * fix(migration): linearize alembic chain after M6 community branch Articles migration now revises 007_create_community; audit_logs revises 007_create_articles so CI has a single head for alembic upgrade head. Co-authored-by: Cursor * test(articles): assert view_count via repo in same transaction Background task commits in a separate session; integration tests use rollback transactions, so increment_article_view_count cannot see uncommitted rows. Co-authored-by: Cursor * fix(articles): address PR #30 review (PEQ-82) - Rename migrations to 008_create_articles and 009_create_audit_logs - Add audit logging for admin create/update/delete article actions - Use atomic SQL for view_count increment with error handling in task - Improve slug fallback for non-Latin titles; register article models in conftest Co-authored-by: Cursor * fix(articles): reload article after update to avoid async lazy-load Partial refresh expired scalars like updated_at and triggered MissingGreenlet when building ArticleResponse after audit log write. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../alembic/versions/008_create_articles.py | 111 ++++++++ ...audit_logs.py => 009_create_audit_logs.py} | 8 +- backend/bruno/articles/create_article.bru | 39 +++ backend/bruno/articles/delete_article.bru | 25 ++ backend/bruno/articles/get_article.bru | 27 ++ backend/bruno/articles/list_articles.bru | 27 ++ backend/bruno/articles/list_tags.bru | 25 ++ backend/bruno/articles/update_article.bru | 33 +++ backend/bruno/collection.bru | 2 +- backend/src/pequi/main.py | 2 + backend/src/pequi/models/__init__.py | 4 + backend/src/pequi/models/article.py | 94 +++++++ .../src/pequi/repositories/article_repo.py | 165 ++++++++++++ backend/src/pequi/routers/article.py | 132 ++++++++++ backend/src/pequi/schemas/article.py | 89 +++++++ backend/src/pequi/services/article_service.py | 14 + backend/src/pequi/tasks/__init__.py | 0 backend/src/pequi/tasks/article_tasks.py | 17 ++ backend/src/pequi/use_cases/create_article.py | 65 +++++ backend/src/pequi/use_cases/delete_article.py | 35 +++ backend/src/pequi/use_cases/get_article.py | 15 ++ .../src/pequi/use_cases/list_article_tags.py | 11 + backend/src/pequi/use_cases/list_articles.py | 32 +++ backend/src/pequi/use_cases/update_article.py | 76 ++++++ backend/src/pequi/utils/__init__.py | 0 backend/src/pequi/utils/slug.py | 30 +++ backend/tests/conftest.py | 1 + .../tests/integration/test_article_flow.py | 249 ++++++++++++++++++ backend/tests/unit/test_article_schema.py | 90 +++++++ docs/milestones/M7-articles.md | 4 +- 30 files changed, 1415 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/008_create_articles.py rename backend/alembic/versions/{008_create_audit_logs.py => 009_create_audit_logs.py} (91%) create mode 100644 backend/bruno/articles/create_article.bru create mode 100644 backend/bruno/articles/delete_article.bru create mode 100644 backend/bruno/articles/get_article.bru create mode 100644 backend/bruno/articles/list_articles.bru create mode 100644 backend/bruno/articles/list_tags.bru create mode 100644 backend/bruno/articles/update_article.bru create mode 100644 backend/src/pequi/models/article.py create mode 100644 backend/src/pequi/repositories/article_repo.py create mode 100644 backend/src/pequi/routers/article.py create mode 100644 backend/src/pequi/schemas/article.py create mode 100644 backend/src/pequi/services/article_service.py create mode 100644 backend/src/pequi/tasks/__init__.py create mode 100644 backend/src/pequi/tasks/article_tasks.py create mode 100644 backend/src/pequi/use_cases/create_article.py create mode 100644 backend/src/pequi/use_cases/delete_article.py create mode 100644 backend/src/pequi/use_cases/get_article.py create mode 100644 backend/src/pequi/use_cases/list_article_tags.py create mode 100644 backend/src/pequi/use_cases/list_articles.py create mode 100644 backend/src/pequi/use_cases/update_article.py create mode 100644 backend/src/pequi/utils/__init__.py create mode 100644 backend/src/pequi/utils/slug.py create mode 100644 backend/tests/integration/test_article_flow.py create mode 100644 backend/tests/unit/test_article_schema.py diff --git a/backend/alembic/versions/008_create_articles.py b/backend/alembic/versions/008_create_articles.py new file mode 100644 index 0000000..7e71a68 --- /dev/null +++ b/backend/alembic/versions/008_create_articles.py @@ -0,0 +1,111 @@ +"""create articles tables — M7 Articles + +Revision ID: 008_create_articles +Revises: 007_create_community +Create Date: 2026-05-27 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "008_create_articles" +down_revision: str | None = "007_create_community" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + article_category_enum = postgresql.ENUM( + "education", + "news", + "guidelines", + "faq", + name="article_category_enum", + ) + article_category_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "article_tags", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.Text(), nullable=False), + sa.UniqueConstraint("name", name="uq_article_tags_name"), + ) + + op.create_table( + "articles", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("slug", sa.Text(), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column( + "category", + postgresql.ENUM( + "education", + "news", + "guidelines", + "faq", + name="article_category_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column("author_name", sa.Text(), nullable=False), + sa.Column("cover_image_url", sa.Text(), nullable=True), + sa.Column("cover_image_key", sa.Text(), nullable=True), + sa.Column("is_published", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reading_time_min", sa.SmallInteger(), nullable=True), + sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("slug", name="uq_articles_slug"), + ) + + op.create_index("ix_articles_slug", "articles", ["slug"]) + op.create_index("ix_articles_category", "articles", ["category"]) + op.create_index("ix_articles_is_published", "articles", ["is_published"]) + op.create_index("ix_articles_published_at", "articles", ["published_at"]) + op.create_index("ix_articles_deleted_at", "articles", ["deleted_at"]) + + op.create_table( + "article_tag_associations", + sa.Column( + "article_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("articles.id", ondelete="RESTRICT"), + primary_key=True, + ), + sa.Column( + "tag_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("article_tags.id", ondelete="RESTRICT"), + primary_key=True, + ), + ) + + +def downgrade() -> None: + op.drop_table("article_tag_associations") + op.drop_index("ix_articles_deleted_at", table_name="articles") + op.drop_index("ix_articles_published_at", table_name="articles") + op.drop_index("ix_articles_is_published", table_name="articles") + op.drop_index("ix_articles_category", table_name="articles") + op.drop_index("ix_articles_slug", table_name="articles") + op.drop_table("articles") + op.drop_table("article_tags") + op.execute("DROP TYPE IF EXISTS article_category_enum") diff --git a/backend/alembic/versions/008_create_audit_logs.py b/backend/alembic/versions/009_create_audit_logs.py similarity index 91% rename from backend/alembic/versions/008_create_audit_logs.py rename to backend/alembic/versions/009_create_audit_logs.py index 1f71125..ec17a00 100644 --- a/backend/alembic/versions/008_create_audit_logs.py +++ b/backend/alembic/versions/009_create_audit_logs.py @@ -1,7 +1,7 @@ """create audit_logs table — LGPD compliance and admin actions audit -Revision ID: 008_create_audit_logs -Revises: 007_create_community +Revision ID: 009_create_audit_logs +Revises: 008_create_articles Create Date: 2026-05-26 """ @@ -11,8 +11,8 @@ from alembic import op from sqlalchemy.dialects import postgresql -revision: str = "008_create_audit_logs" -down_revision: str | None = "007_create_community" +revision: str = "009_create_audit_logs" +down_revision: str | None = "008_create_articles" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/backend/bruno/articles/create_article.bru b/backend/bruno/articles/create_article.bru new file mode 100644 index 0000000..7776846 --- /dev/null +++ b/backend/bruno/articles/create_article.bru @@ -0,0 +1,39 @@ +meta { + name: Create Article + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/v1/articles + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +body:json { + { + "title": "Tratamento da Hanseníase", + "summary": "Visão geral do tratamento multidroga e adesão.", + "content": "Conteúdo educativo em markdown sobre o tratamento.", + "category": "education", + "author_name": "Equipe Pequi", + "is_published": true, + "tags": ["tratamento", "adesao"] + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.slug: isDefined +} + +docs { + Cria artigo (admin). Slug gerado automaticamente. + + Rate limit: 10/hora. +} diff --git a/backend/bruno/articles/delete_article.bru b/backend/bruno/articles/delete_article.bru new file mode 100644 index 0000000..6e53d1d --- /dev/null +++ b/backend/bruno/articles/delete_article.bru @@ -0,0 +1,25 @@ +meta { + name: Delete Article + type: http + seq: 6 +} + +delete { + url: {{baseUrl}}/v1/articles/{{articleId}} + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 204 +} + +docs { + Soft delete do artigo (admin). + + Rate limit: 10/hora. +} diff --git a/backend/bruno/articles/get_article.bru b/backend/bruno/articles/get_article.bru new file mode 100644 index 0000000..cdeb711 --- /dev/null +++ b/backend/bruno/articles/get_article.bru @@ -0,0 +1,27 @@ +meta { + name: Get Article by Slug + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/articles/{{articleSlug}} + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.slug: isDefined + res.body.view_count: isDefined +} + +docs { + Detalhe do artigo por slug. Incrementa view_count em background. + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/articles/list_articles.bru b/backend/bruno/articles/list_articles.bru new file mode 100644 index 0000000..2ea57e3 --- /dev/null +++ b/backend/bruno/articles/list_articles.bru @@ -0,0 +1,27 @@ +meta { + name: List Articles + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/v1/articles?limit=20&offset=0 + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.items: isDefined + res.body.total: isDefined +} + +docs { + Lista artigos publicados (paciente/profissional) ou todos não deletados (admin). + + Rate limit: 100/minuto. +} diff --git a/backend/bruno/articles/list_tags.bru b/backend/bruno/articles/list_tags.bru new file mode 100644 index 0000000..3211b6e --- /dev/null +++ b/backend/bruno/articles/list_tags.bru @@ -0,0 +1,25 @@ +meta { + name: List Article Tags + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v1/articles/tags + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 +} + +docs { + Tags cadastradas, ordenadas alfabeticamente. + + Rate limit: 200/minuto. +} diff --git a/backend/bruno/articles/update_article.bru b/backend/bruno/articles/update_article.bru new file mode 100644 index 0000000..98649bd --- /dev/null +++ b/backend/bruno/articles/update_article.bru @@ -0,0 +1,33 @@ +meta { + name: Update Article + type: http + seq: 5 +} + +patch { + url: {{baseUrl}}/v1/articles/{{articleId}} + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +body:json { + { + "is_published": true, + "summary": "Resumo atualizado com tamanho mínimo adequado." + } +} + +assert { + res.status: eq 200 + res.body.id: isDefined +} + +docs { + Atualiza artigo (admin). + + Rate limit: 10/hora. +} diff --git a/backend/bruno/collection.bru b/backend/bruno/collection.bru index 6eaae98..dff73a2 100644 --- a/backend/bruno/collection.bru +++ b/backend/bruno/collection.bru @@ -20,7 +20,7 @@ docs { | `checkin/` | M4 | 🔜 | | `body_map/` | M5 | 🔜 | | `community/` | M6 | 🔜 | - | `articles/` | M7 | 🔜 | + | `articles/` | M7 | ✅ | | `professional/` | M8 | 🔜 | | `account/` | M11 | 🔜 | diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 2a91474..cd6769f 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -66,6 +66,7 @@ async def health_check() -> JSONResponse: app.include_router(health_router) + from pequi.routers import article as article_router from pequi.routers import auth as auth_router from pequi.routers import body_map as body_map_router from pequi.routers import checkin as checkin_router @@ -81,6 +82,7 @@ async def health_check() -> JSONResponse: app.include_router(checkin_router.alerts_router, prefix="/v1/alerts", tags=["alerts"]) app.include_router(body_map_router.router, prefix="/v1/body-map", tags=["body-map"]) app.include_router(body_map_router.areas_router, prefix="/v1/body-areas", tags=["body-map"]) + app.include_router(article_router.router, prefix="/v1/articles", tags=["articles"]) app.include_router(community_router.router, prefix="/v1/community", tags=["community"]) app.include_router( community_router.admin_router, diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index fe87192..8be4197 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -1,4 +1,5 @@ from pequi.models.alert import Alert +from pequi.models.article import Article, ArticleCategory, ArticleTag from pequi.models.audit_log import AuditLog from pequi.models.body_map import BodyArea, BodyAreaHistory, BodyMapEntry from pequi.models.checkin import Checkin @@ -20,6 +21,9 @@ __all__ = [ "AdherenceSnapshot", "Alert", + "Article", + "ArticleCategory", + "ArticleTag", "AuditLog", "BodyArea", "BodyAreaHistory", diff --git a/backend/src/pequi/models/article.py b/backend/src/pequi/models/article.py new file mode 100644 index 0000000..1546815 --- /dev/null +++ b/backend/src/pequi/models/article.py @@ -0,0 +1,94 @@ +import uuid +from enum import StrEnum + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + SmallInteger, + Table, + Text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from pequi.database import Base + + +class ArticleCategory(StrEnum): + education = "education" + news = "news" + guidelines = "guidelines" + faq = "faq" + + +article_tag_associations = Table( + "article_tag_associations", + Base.metadata, + Column( + "article_id", + UUID(as_uuid=True), + ForeignKey("articles.id", ondelete="RESTRICT"), + primary_key=True, + ), + Column( + "tag_id", + UUID(as_uuid=True), + ForeignKey("article_tags.id", ondelete="RESTRICT"), + primary_key=True, + ), +) + + +class ArticleTag(Base): + __tablename__ = "article_tags" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(Text, nullable=False, unique=True) + + +class Article(Base): + __tablename__ = "articles" + __table_args__ = ( + Index("ix_articles_slug", "slug"), + Index("ix_articles_category", "category"), + Index("ix_articles_is_published", "is_published"), + Index("ix_articles_published_at", "published_at"), + Index("ix_articles_deleted_at", "deleted_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(Text, nullable=False) + slug = Column(Text, nullable=False, unique=True) + summary = Column(Text, nullable=False) + content = Column(Text, nullable=False) + category = Column( + Enum(ArticleCategory, name="article_category_enum"), + nullable=False, + ) + author_name = Column(Text, nullable=False) + cover_image_url = Column(Text, nullable=True) + cover_image_key = Column(Text, nullable=True) + is_published = Column(Boolean, nullable=False, server_default="false", default=False) + published_at = Column(DateTime(timezone=True), nullable=True) + reading_time_min = Column(SmallInteger, nullable=True) + view_count = Column(Integer, nullable=False, server_default="0", default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + nullable=False, + ) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + tags = relationship( + "ArticleTag", + secondary=article_tag_associations, + lazy="selectin", + ) diff --git a/backend/src/pequi/repositories/article_repo.py b/backend/src/pequi/repositories/article_repo.py new file mode 100644 index 0000000..a25af9e --- /dev/null +++ b/backend/src/pequi/repositories/article_repo.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import func, or_, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from pequi.core.exceptions import NotFoundError +from pequi.models.article import Article, ArticleCategory, ArticleTag, article_tag_associations + + +class ArticleRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list( + self, + *, + limit: int, + offset: int, + category: ArticleCategory | None = None, + tag_name: str | None = None, + title_search: str | None = None, + published_only: bool = True, + ) -> tuple[list[Article], int]: + base = ( + select(Article).where(Article.deleted_at.is_(None)).options(selectinload(Article.tags)) + ) + if published_only: + base = base.where(Article.is_published.is_(True)) + + if category is not None: + base = base.where(Article.category == category) + + if title_search: + pattern = f"%{title_search.strip()}%" + base = base.where(Article.title.ilike(pattern)) + + if tag_name: + normalized = tag_name.strip().lower() + base = ( + base.join(article_tag_associations) + .join(ArticleTag) + .where(func.lower(ArticleTag.name) == normalized) + ) + + count_stmt = select(func.count()).select_from(base.subquery()) + total = (await self._session.execute(count_stmt)).scalar_one() + + stmt = base.order_by(Article.published_at.desc().nullslast()).limit(limit).offset(offset) + result = await self._session.execute(stmt) + return list(result.scalars().unique().all()), total + + async def get_by_slug( + self, + slug: str, + *, + published_only: bool = True, + ) -> Article | None: + stmt = ( + select(Article) + .where(Article.slug == slug, Article.deleted_at.is_(None)) + .options(selectinload(Article.tags)) + ) + if published_only: + stmt = stmt.where(Article.is_published.is_(True)) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_id(self, article_id: UUID) -> Article | None: + stmt = ( + select(Article) + .where(Article.id == article_id, Article.deleted_at.is_(None)) + .options(selectinload(Article.tags)) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_id_or_raise(self, article_id: UUID) -> Article: + article = await self.get_by_id(article_id) + if article is None: + raise NotFoundError("Article", str(article_id)) + return article + + async def list_slugs_with_prefix( + self, + base_slug: str, + *, + exclude_id: UUID | None = None, + ) -> list[str]: + stmt = select(Article.slug).where( + Article.deleted_at.is_(None), + or_(Article.slug == base_slug, Article.slug.like(f"{base_slug}-%")), + ) + if exclude_id is not None: + stmt = stmt.where(Article.id != exclude_id) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def create(self, article: Article, tag_names: list[str]) -> Article: + tags = await self.get_or_create_tags(tag_names) + article.tags = tags + self._session.add(article) + await self._session.flush() + reloaded = await self.get_by_id(article.id) + assert reloaded is not None + return reloaded + + async def update(self, article: Article, tag_names: list[str] | None) -> Article: + if tag_names is not None: + article.tags = await self.get_or_create_tags(tag_names) + await self._session.flush() + reloaded = await self.get_by_id(article.id) + assert reloaded is not None + return reloaded + + async def soft_delete(self, article_id: UUID) -> None: + article = await self.get_by_id_or_raise(article_id) + article.deleted_at = datetime.now(UTC) + await self._session.flush() + + async def increment_view_count(self, article_id: UUID) -> None: + # UPDATE atômico no PostgreSQL: view_count = view_count + 1 + await self._session.execute( + text( + "UPDATE articles SET view_count = view_count + 1 " + "WHERE id = :article_id AND deleted_at IS NULL" + ), + {"article_id": article_id}, + ) + + async def get_or_create_tags(self, names: list[str]) -> list[ArticleTag]: + if not names: + return [] + normalized_names = [] + seen: set[str] = set() + for raw in names: + name = raw.strip().lower() + if not name or name in seen: + continue + seen.add(name) + normalized_names.append(name) + if not normalized_names: + return [] + + stmt = select(ArticleTag).where(ArticleTag.name.in_(normalized_names)) + existing = {t.name: t for t in (await self._session.execute(stmt)).scalars().all()} + + tags: list[ArticleTag] = [] + for name in normalized_names: + if name in existing: + tags.append(existing[name]) + else: + tag = ArticleTag(name=name) + self._session.add(tag) + tags.append(tag) + await self._session.flush() + return tags + + async def list_all_tags(self) -> list[ArticleTag]: + stmt = select(ArticleTag).order_by(ArticleTag.name.asc()) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/pequi/routers/article.py b/backend/src/pequi/routers/article.py new file mode 100644 index 0000000..51c346a --- /dev/null +++ b/backend/src/pequi/routers/article.py @@ -0,0 +1,132 @@ +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import ( + get_actor_from_token, + get_current_admin, + get_current_user, + get_db, +) +from pequi.core.rate_limit import user_limiter +from pequi.models.article import ArticleCategory +from pequi.repositories.article_repo import ArticleRepository +from pequi.repositories.audit_repo import AuditRepository +from pequi.schemas.article import ( + ArticleCreate, + ArticleListResponse, + ArticleResponse, + ArticleTagResponse, + ArticleUpdate, +) +from pequi.tasks.article_tasks import increment_article_view_count +from pequi.use_cases.create_article import CreateArticleUseCase +from pequi.use_cases.delete_article import DeleteArticleUseCase +from pequi.use_cases.get_article import GetArticleUseCase +from pequi.use_cases.list_article_tags import ListArticleTagsUseCase +from pequi.use_cases.list_articles import ListArticlesUseCase +from pequi.use_cases.update_article import UpdateArticleUseCase + +router = APIRouter() + + +def _article_repo(session: AsyncSession = Depends(get_db)) -> ArticleRepository: + return ArticleRepository(session) + + +def _article_repos( + session: AsyncSession = Depends(get_db), +) -> tuple[ArticleRepository, AuditRepository]: + return ArticleRepository(session), AuditRepository(session) + + +@router.get("/tags", response_model=list[ArticleTagResponse]) +@user_limiter.limit("200/minute") +async def list_article_tags( + request: Request, + _user_id: UUID = Depends(get_current_user), + repo: ArticleRepository = Depends(_article_repo), +) -> list[ArticleTagResponse]: + use_case = ListArticleTagsUseCase(repo) + return await use_case.execute() + + +@router.get("", response_model=ArticleListResponse) +@user_limiter.limit("100/minute") +async def list_articles( + request: Request, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + repo: ArticleRepository = Depends(_article_repo), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + category: ArticleCategory | None = None, + tag: str | None = None, + search: str | None = None, +) -> ArticleListResponse: + _, role = actor + use_case = ListArticlesUseCase(repo) + return await use_case.execute( + actor_role=role, + limit=limit, + offset=offset, + category=category, + tag=tag, + search=search, + ) + + +@router.get("/{slug}", response_model=ArticleResponse) +@user_limiter.limit("100/minute") +async def get_article( + request: Request, + slug: str, + background_tasks: BackgroundTasks, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + repo: ArticleRepository = Depends(_article_repo), +) -> ArticleResponse: + _, role = actor + use_case = GetArticleUseCase(repo) + result = await use_case.execute(slug, actor_role=role) + background_tasks.add_task(increment_article_view_count, result.id) + return result + + +@router.post("", response_model=ArticleResponse, status_code=status.HTTP_201_CREATED) +@user_limiter.limit("10/hour") +async def create_article( + request: Request, + body: ArticleCreate, + admin_id: UUID = Depends(get_current_admin), + repos: tuple[ArticleRepository, AuditRepository] = Depends(_article_repos), +) -> ArticleResponse: + article_repo, audit_repo = repos + use_case = CreateArticleUseCase(article_repo, audit_repo) + return await use_case.execute(admin_id, body) + + +@router.patch("/{article_id}", response_model=ArticleResponse) +@user_limiter.limit("10/hour") +async def update_article( + request: Request, + article_id: UUID, + body: ArticleUpdate, + admin_id: UUID = Depends(get_current_admin), + repos: tuple[ArticleRepository, AuditRepository] = Depends(_article_repos), +) -> ArticleResponse: + article_repo, audit_repo = repos + use_case = UpdateArticleUseCase(article_repo, audit_repo) + return await use_case.execute(admin_id, article_id, body) + + +@router.delete("/{article_id}", status_code=status.HTTP_204_NO_CONTENT) +@user_limiter.limit("10/hour") +async def delete_article( + request: Request, + article_id: UUID, + admin_id: UUID = Depends(get_current_admin), + repos: tuple[ArticleRepository, AuditRepository] = Depends(_article_repos), +) -> None: + article_repo, audit_repo = repos + use_case = DeleteArticleUseCase(article_repo, audit_repo) + await use_case.execute(admin_id, article_id) diff --git a/backend/src/pequi/schemas/article.py b/backend/src/pequi/schemas/article.py new file mode 100644 index 0000000..9e7d2b4 --- /dev/null +++ b/backend/src/pequi/schemas/article.py @@ -0,0 +1,89 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from pequi.models.article import ArticleCategory + + +class ArticleTagResponse(BaseModel): + id: UUID + name: str + + model_config = ConfigDict(from_attributes=True) + + +class ArticleCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(..., min_length=1, max_length=500) + summary: str = Field(..., min_length=10, max_length=2000) + content: str = Field(..., min_length=1) + category: ArticleCategory + author_name: str = Field(..., min_length=1, max_length=200) + cover_image_url: str | None = None + cover_image_key: str | None = None + is_published: bool = False + tags: list[str] = Field(default_factory=list) + + +class ArticleUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str | None = Field(default=None, min_length=1, max_length=500) + summary: str | None = Field(default=None, min_length=10, max_length=2000) + content: str | None = Field(default=None, min_length=1) + category: ArticleCategory | None = None + author_name: str | None = Field(default=None, min_length=1, max_length=200) + cover_image_url: str | None = None + cover_image_key: str | None = None + is_published: bool | None = None + tags: list[str] | None = None + + +class ArticleResponse(BaseModel): + id: UUID + title: str + slug: str + summary: str + content: str + category: ArticleCategory + author_name: str + cover_image_url: str | None + cover_image_key: str | None + is_published: bool + published_at: datetime | None + reading_time_min: int | None + view_count: int + tags: list[ArticleTagResponse] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ArticleListResponse(BaseModel): + items: list[ArticleResponse] + total: int + + +def article_to_response(article) -> ArticleResponse: + tags = article.tags or [] + return ArticleResponse( + id=article.id, + title=article.title, + slug=article.slug, + summary=article.summary, + content=article.content, + category=article.category, + author_name=article.author_name, + cover_image_url=article.cover_image_url, + cover_image_key=article.cover_image_key, + is_published=article.is_published, + published_at=article.published_at, + reading_time_min=article.reading_time_min, + view_count=article.view_count, + tags=[ArticleTagResponse.model_validate(t) for t in tags], + created_at=article.created_at, + updated_at=article.updated_at, + ) diff --git a/backend/src/pequi/services/article_service.py b/backend/src/pequi/services/article_service.py new file mode 100644 index 0000000..65d4e1c --- /dev/null +++ b/backend/src/pequi/services/article_service.py @@ -0,0 +1,14 @@ +import math + + +class ArticleService: + """Regras puras de artigo (sem acesso a banco).""" + + WORDS_PER_MINUTE = 200 + + @staticmethod + def calculate_reading_time_min(content: str) -> int: + word_count = len(content.split()) + if word_count == 0: + return 0 + return math.ceil(word_count / ArticleService.WORDS_PER_MINUTE) diff --git a/backend/src/pequi/tasks/__init__.py b/backend/src/pequi/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pequi/tasks/article_tasks.py b/backend/src/pequi/tasks/article_tasks.py new file mode 100644 index 0000000..18d068e --- /dev/null +++ b/backend/src/pequi/tasks/article_tasks.py @@ -0,0 +1,17 @@ +from uuid import UUID + +from pequi.core.logging import get_logger +from pequi.database import AsyncSessionLocal +from pequi.repositories.article_repo import ArticleRepository + +logger = get_logger(__name__) + + +async def increment_article_view_count(article_id: UUID) -> None: + try: + async with AsyncSessionLocal() as session: + repo = ArticleRepository(session) + await repo.increment_view_count(article_id) + await session.commit() + except Exception: + logger.exception("article.view_count_increment_failed", article_id=str(article_id)) diff --git a/backend/src/pequi/use_cases/create_article.py b/backend/src/pequi/use_cases/create_article.py new file mode 100644 index 0000000..49d16ec --- /dev/null +++ b/backend/src/pequi/use_cases/create_article.py @@ -0,0 +1,65 @@ +import uuid +from datetime import UTC, datetime +from uuid import UUID + +from pequi.core.logging import get_logger +from pequi.models.article import Article +from pequi.repositories.article_repo import ArticleRepository +from pequi.repositories.audit_repo import AuditRepository +from pequi.schemas.article import ArticleCreate, ArticleResponse, article_to_response +from pequi.services.article_service import ArticleService +from pequi.utils.slug import slug_base_from_title, unique_slug + +logger = get_logger(__name__) + + +class CreateArticleUseCase: + def __init__( + self, + article_repo: ArticleRepository, + audit_repo: AuditRepository, + ) -> None: + self._article_repo = article_repo + self._audit_repo = audit_repo + + async def execute(self, admin_user_id: UUID, data: ArticleCreate) -> ArticleResponse: + base_slug = slug_base_from_title(data.title) + existing = await self._article_repo.list_slugs_with_prefix(base_slug) + slug = unique_slug(base_slug, existing) + + content = data.content + reading_time = ArticleService.calculate_reading_time_min(content) + published_at = datetime.now(UTC) if data.is_published else None + + article = Article( + id=uuid.uuid4(), + title=data.title, + slug=slug, + summary=data.summary, + content=content, + category=data.category, + author_name=data.author_name, + cover_image_url=data.cover_image_url, + cover_image_key=data.cover_image_key, + is_published=data.is_published, + published_at=published_at, + reading_time_min=reading_time, + ) + created = await self._article_repo.create(article, data.tags) + + await self._audit_repo.log_action( + actor_user_id=admin_user_id, + actor_role="admin", + entity_type="article", + entity_id=str(created.id), + action="create", + details=f"slug={created.slug}, is_published={created.is_published}", + ) + logger.info( + "article.created", + admin_user_id=str(admin_user_id), + article_id=str(created.id), + slug=created.slug, + ) + + return article_to_response(created) diff --git a/backend/src/pequi/use_cases/delete_article.py b/backend/src/pequi/use_cases/delete_article.py new file mode 100644 index 0000000..0ec4364 --- /dev/null +++ b/backend/src/pequi/use_cases/delete_article.py @@ -0,0 +1,35 @@ +from uuid import UUID + +from pequi.core.logging import get_logger +from pequi.repositories.article_repo import ArticleRepository +from pequi.repositories.audit_repo import AuditRepository + +logger = get_logger(__name__) + + +class DeleteArticleUseCase: + def __init__( + self, + article_repo: ArticleRepository, + audit_repo: AuditRepository, + ) -> None: + self._article_repo = article_repo + self._audit_repo = audit_repo + + async def execute(self, admin_user_id: UUID, article_id: UUID) -> None: + article = await self._article_repo.get_by_id_or_raise(article_id) + await self._article_repo.soft_delete(article_id) + + await self._audit_repo.log_action( + actor_user_id=admin_user_id, + actor_role="admin", + entity_type="article", + entity_id=str(article_id), + action="delete", + details=f"slug={article.slug}", + ) + logger.info( + "article.deleted", + admin_user_id=str(admin_user_id), + article_id=str(article_id), + ) diff --git a/backend/src/pequi/use_cases/get_article.py b/backend/src/pequi/use_cases/get_article.py new file mode 100644 index 0000000..9a7bb15 --- /dev/null +++ b/backend/src/pequi/use_cases/get_article.py @@ -0,0 +1,15 @@ +from pequi.core.exceptions import NotFoundError +from pequi.repositories.article_repo import ArticleRepository +from pequi.schemas.article import ArticleResponse, article_to_response + + +class GetArticleUseCase: + def __init__(self, article_repo: ArticleRepository) -> None: + self._article_repo = article_repo + + async def execute(self, slug: str, *, actor_role: str) -> ArticleResponse: + published_only = actor_role != "admin" + article = await self._article_repo.get_by_slug(slug, published_only=published_only) + if article is None: + raise NotFoundError("Article", slug) + return article_to_response(article) diff --git a/backend/src/pequi/use_cases/list_article_tags.py b/backend/src/pequi/use_cases/list_article_tags.py new file mode 100644 index 0000000..35ad65e --- /dev/null +++ b/backend/src/pequi/use_cases/list_article_tags.py @@ -0,0 +1,11 @@ +from pequi.repositories.article_repo import ArticleRepository +from pequi.schemas.article import ArticleTagResponse + + +class ListArticleTagsUseCase: + def __init__(self, article_repo: ArticleRepository) -> None: + self._article_repo = article_repo + + async def execute(self) -> list[ArticleTagResponse]: + tags = await self._article_repo.list_all_tags() + return [ArticleTagResponse.model_validate(t) for t in tags] diff --git a/backend/src/pequi/use_cases/list_articles.py b/backend/src/pequi/use_cases/list_articles.py new file mode 100644 index 0000000..f727cee --- /dev/null +++ b/backend/src/pequi/use_cases/list_articles.py @@ -0,0 +1,32 @@ +from pequi.models.article import ArticleCategory +from pequi.repositories.article_repo import ArticleRepository +from pequi.schemas.article import ArticleListResponse, article_to_response + + +class ListArticlesUseCase: + def __init__(self, article_repo: ArticleRepository) -> None: + self._article_repo = article_repo + + async def execute( + self, + *, + actor_role: str, + limit: int = 50, + offset: int = 0, + category: ArticleCategory | None = None, + tag: str | None = None, + search: str | None = None, + ) -> ArticleListResponse: + published_only = actor_role != "admin" + articles, total = await self._article_repo.list( + limit=limit, + offset=offset, + category=category, + tag_name=tag, + title_search=search, + published_only=published_only, + ) + return ArticleListResponse( + items=[article_to_response(a) for a in articles], + total=total, + ) diff --git a/backend/src/pequi/use_cases/update_article.py b/backend/src/pequi/use_cases/update_article.py new file mode 100644 index 0000000..48a3a50 --- /dev/null +++ b/backend/src/pequi/use_cases/update_article.py @@ -0,0 +1,76 @@ +from datetime import UTC, datetime +from uuid import UUID + +from pequi.core.logging import get_logger +from pequi.repositories.article_repo import ArticleRepository +from pequi.repositories.audit_repo import AuditRepository +from pequi.schemas.article import ArticleResponse, ArticleUpdate, article_to_response +from pequi.services.article_service import ArticleService +from pequi.utils.slug import slug_base_from_title, unique_slug + +logger = get_logger(__name__) + + +class UpdateArticleUseCase: + def __init__( + self, + article_repo: ArticleRepository, + audit_repo: AuditRepository, + ) -> None: + self._article_repo = article_repo + self._audit_repo = audit_repo + + async def execute( + self, + admin_user_id: UUID, + article_id: UUID, + data: ArticleUpdate, + ) -> ArticleResponse: + article = await self._article_repo.get_by_id_or_raise(article_id) + fields_set = data.model_fields_set + + if "title" in fields_set and data.title is not None: + article.title = data.title + base_slug = slug_base_from_title(data.title) + existing = await self._article_repo.list_slugs_with_prefix( + base_slug, exclude_id=article.id + ) + article.slug = unique_slug(base_slug, existing) + + if "summary" in fields_set and data.summary is not None: + article.summary = data.summary + if "content" in fields_set and data.content is not None: + article.content = data.content + article.reading_time_min = ArticleService.calculate_reading_time_min(data.content) + if "category" in fields_set and data.category is not None: + article.category = data.category + if "author_name" in fields_set and data.author_name is not None: + article.author_name = data.author_name + if "cover_image_url" in fields_set: + article.cover_image_url = data.cover_image_url + if "cover_image_key" in fields_set: + article.cover_image_key = data.cover_image_key + + if "is_published" in fields_set and data.is_published is not None: + article.is_published = data.is_published + if data.is_published and article.published_at is None: + article.published_at = datetime.now(UTC) + + tag_names = data.tags if "tags" in fields_set else None + updated = await self._article_repo.update(article, tag_names) + + await self._audit_repo.log_action( + actor_user_id=admin_user_id, + actor_role="admin", + entity_type="article", + entity_id=str(article_id), + action="update", + details=f"fields={sorted(fields_set)}", + ) + logger.info( + "article.updated", + admin_user_id=str(admin_user_id), + article_id=str(article_id), + ) + + return article_to_response(updated) diff --git a/backend/src/pequi/utils/__init__.py b/backend/src/pequi/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pequi/utils/slug.py b/backend/src/pequi/utils/slug.py new file mode 100644 index 0000000..fe6d2a8 --- /dev/null +++ b/backend/src/pequi/utils/slug.py @@ -0,0 +1,30 @@ +import re +import unicodedata +from uuid import uuid4 + + +def slugify_title(title: str) -> str: + """Normaliza título para slug URL: minúsculas, sem acentos, hífens.""" + normalized = unicodedata.normalize("NFKD", title) + ascii_text = normalized.encode("ascii", "ignore").decode("ascii") + lowered = ascii_text.lower() + slug = re.sub(r"[^a-z0-9]+", "-", lowered) + return slug.strip("-") + + +def slug_base_from_title(title: str) -> str: + """Retorna base de slug; se título não gera caracteres latinos, usa sufixo único.""" + base = slugify_title(title) + if base: + return base + return f"artigo-{uuid4().hex[:8]}" + + +def unique_slug(base_slug: str, existing_slugs: list[str]) -> str: + """Retorna slug único acrescentando sufixo numérico se necessário.""" + if base_slug not in existing_slugs: + return base_slug + suffix = 2 + while f"{base_slug}-{suffix}" in existing_slugs: + suffix += 1 + return f"{base_slug}-{suffix}" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7beeca3..dafdedb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ from sqlalchemy.pool import NullPool import pequi.models # noqa: F401 — registra todas as tabelas no metadata antes do create_all +import pequi.models.article # noqa: F401 — ensure article models are registered import pequi.models.community # noqa: F401 — ensure community models are registered from pequi.config import get_settings from pequi.core.dependencies import get_db diff --git a/backend/tests/integration/test_article_flow.py b/backend/tests/integration/test_article_flow.py new file mode 100644 index 0000000..fd9c040 --- /dev/null +++ b/backend/tests/integration/test_article_flow.py @@ -0,0 +1,249 @@ +"""Testes de integração do módulo M7 — Articles.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from pequi.core.exceptions import NotFoundError +from pequi.models.article import ArticleCategory +from pequi.models.audit_log import AuditLog +from pequi.repositories.article_repo import ArticleRepository +from pequi.repositories.audit_repo import AuditRepository +from pequi.schemas.article import ArticleCreate, ArticleUpdate +from pequi.use_cases.create_article import CreateArticleUseCase +from pequi.use_cases.delete_article import DeleteArticleUseCase +from pequi.use_cases.get_article import GetArticleUseCase +from pequi.use_cases.list_articles import ListArticlesUseCase +from pequi.use_cases.update_article import UpdateArticleUseCase + +ADMIN_USER_ID = uuid4() + + +def _article_repos(session) -> tuple[ArticleRepository, AuditRepository]: + return ArticleRepository(session), AuditRepository(session) + + +def _sample_create(**overrides) -> ArticleCreate: + data = { + "title": "Tratamento da Hanseníase", + "summary": "Resumo educativo sobre o tratamento multidroga.", + "content": "Conteúdo completo em markdown sobre o tratamento.", + "category": ArticleCategory.education, + "author_name": "Equipe Pequi", + "tags": ["tratamento", "sintomas"], + "is_published": False, + } + data.update(overrides) + return ArticleCreate(**data) + + +@pytest.mark.asyncio +async def test_admin_can_create_article_with_tags_and_slug(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + result = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, _sample_create() + ) + + assert result.slug == "tratamento-da-hanseniase" + assert len(result.tags) == 2 + assert result.is_published is False + assert result.reading_time_min >= 1 + + +@pytest.mark.asyncio +async def test_duplicate_title_generates_unique_slug(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + create_uc = CreateArticleUseCase(article_repo, audit_repo) + + first = await create_uc.execute(ADMIN_USER_ID, _sample_create()) + second = await create_uc.execute( + ADMIN_USER_ID, _sample_create(title="Tratamento da Hanseníase") + ) + + assert first.slug == "tratamento-da-hanseniase" + assert second.slug == "tratamento-da-hanseniase-2" + + +@pytest.mark.asyncio +async def test_patient_only_sees_published_articles(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + create_uc = CreateArticleUseCase(article_repo, audit_repo) + + draft = await create_uc.execute( + ADMIN_USER_ID, _sample_create(title="Rascunho", is_published=False) + ) + published = await create_uc.execute( + ADMIN_USER_ID, + _sample_create( + title="Artigo Publicado", + summary="Resumo publicado com tamanho mínimo ok.", + is_published=True, + ), + ) + + list_patient = await ListArticlesUseCase(article_repo).execute(actor_role="patient") + assert list_patient.total == 1 + assert list_patient.items[0].id == published.id + + with pytest.raises(NotFoundError): + await GetArticleUseCase(article_repo).execute(draft.slug, actor_role="patient") + + +@pytest.mark.asyncio +async def test_admin_sees_drafts_in_list(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + create_uc = CreateArticleUseCase(article_repo, audit_repo) + await create_uc.execute(ADMIN_USER_ID, _sample_create(is_published=False)) + await create_uc.execute( + ADMIN_USER_ID, + _sample_create( + title="Publicado Admin", + summary="Resumo publicado com tamanho mínimo ok.", + is_published=True, + ), + ) + + listed = await ListArticlesUseCase(article_repo).execute(actor_role="admin") + assert listed.total == 2 + + +@pytest.mark.asyncio +async def test_publish_and_filter_by_category_and_tag(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + created = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + _sample_create(is_published=True, tags=["prevencao"]), + ) + + await UpdateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + created.id, + ArticleUpdate(is_published=True), + ) + + by_category = await ListArticlesUseCase(article_repo).execute( + actor_role="patient", + category=ArticleCategory.education, + ) + assert by_category.total >= 1 + + by_tag = await ListArticlesUseCase(article_repo).execute( + actor_role="patient", + tag="prevencao", + ) + assert by_tag.total == 1 + + by_search = await ListArticlesUseCase(article_repo).execute( + actor_role="patient", + search="Hanseníase", + ) + assert by_search.total == 1 + + +@pytest.mark.asyncio +async def test_soft_delete_hides_from_list(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + created = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + _sample_create(is_published=True, title="Para deletar"), + ) + + await DeleteArticleUseCase(article_repo, audit_repo).execute(ADMIN_USER_ID, created.id) + + listed = await ListArticlesUseCase(article_repo).execute(actor_role="admin") + assert listed.total == 0 + + with pytest.raises(NotFoundError): + await GetArticleUseCase(article_repo).execute(created.slug, actor_role="admin") + + +@pytest.mark.asyncio +async def test_admin_can_get_unpublished_draft(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + created = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, _sample_create(is_published=False) + ) + + result = await GetArticleUseCase(article_repo).execute(created.slug, actor_role="admin") + assert result.id == created.id + assert result.is_published is False + + +@pytest.mark.asyncio +async def test_increment_view_count_persists(create_tables, db_session): + """Incremento atômico no repositório. + + A task em background (``increment_article_view_count``) abre sessão própria e + faz commit — invisível a esta transação de teste (rollback). O router usa essa + task após responder; a persistência do UPDATE é validada aqui na mesma sessão. + """ + article_repo, audit_repo = _article_repos(db_session) + created = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + _sample_create(is_published=True, title="Com views"), + ) + assert created.view_count == 0 + + await article_repo.increment_view_count(created.id) + await article_repo.increment_view_count(created.id) + await db_session.flush() + + article = await article_repo.get_by_id(created.id) + assert article is not None + assert article.view_count == 2 + + +@pytest.mark.asyncio +async def test_tags_created_on_the_fly(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + _sample_create(tags=["nova-tag-unica"], is_published=True), + ) + + tags = await article_repo.list_all_tags() + names = [t.name for t in tags] + assert "nova-tag-unica" in names + assert names == sorted(names) + + +@pytest.mark.asyncio +async def test_non_admin_cannot_access_via_use_case_visibility_only(create_tables, db_session): + """Garante que regra de publicação está no use case (não só no router).""" + article_repo, audit_repo = _article_repos(db_session) + created = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + _sample_create(is_published=True, title="Profissional lê"), + ) + + result = await GetArticleUseCase(article_repo).execute( + created.slug, actor_role="health_professional" + ) + assert result.id == created.id + + +@pytest.mark.asyncio +async def test_admin_crud_writes_audit_log(create_tables, db_session): + article_repo, audit_repo = _article_repos(db_session) + + created = await CreateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, _sample_create(is_published=True, title="Auditado") + ) + await UpdateArticleUseCase(article_repo, audit_repo).execute( + ADMIN_USER_ID, + created.id, + ArticleUpdate(summary="Resumo atualizado com tamanho mínimo adequado."), + ) + await DeleteArticleUseCase(article_repo, audit_repo).execute(ADMIN_USER_ID, created.id) + + count_stmt = ( + select(func.count()) + .select_from(AuditLog) + .where( + AuditLog.entity_type == "article", + AuditLog.entity_id == str(created.id), + ) + ) + total = (await db_session.execute(count_stmt)).scalar_one() + assert total == 3 diff --git a/backend/tests/unit/test_article_schema.py b/backend/tests/unit/test_article_schema.py new file mode 100644 index 0000000..abf71dc --- /dev/null +++ b/backend/tests/unit/test_article_schema.py @@ -0,0 +1,90 @@ +import math + +import pytest +from pydantic import ValidationError + +from pequi.models.article import ArticleCategory +from pequi.schemas.article import ArticleCreate, ArticleUpdate +from pequi.services.article_service import ArticleService +from pequi.utils.slug import slug_base_from_title, slugify_title, unique_slug + + +def test_article_create_requires_title_and_content(): + with pytest.raises(ValidationError): + ArticleCreate( + title="", + summary="Resumo válido com dez chars", + content="conteúdo", + category=ArticleCategory.education, + author_name="Autor", + ) + + with pytest.raises(ValidationError): + ArticleCreate( + title="Título", + summary="Resumo válido com dez chars", + content="", + category=ArticleCategory.education, + author_name="Autor", + ) + + +def test_article_create_summary_min_length(): + with pytest.raises(ValidationError): + ArticleCreate( + title="Título", + summary="curto", + content="conteúdo com palavras", + category=ArticleCategory.education, + author_name="Autor", + ) + + +def test_article_create_valid_category(): + article = ArticleCreate( + title="Título", + summary="Resumo com tamanho adequado para preview", + content="Conteúdo markdown do artigo.", + category=ArticleCategory.guidelines, + author_name="Dr. Silva", + tags=["tratamento"], + ) + assert article.category == ArticleCategory.guidelines + + +def test_article_update_allows_partial(): + update = ArticleUpdate(is_published=True) + assert update.is_published is True + assert update.title is None + + +def test_slugify_title_removes_accents_and_special_chars(): + assert slugify_title("Tratamento da Hanseníase") == "tratamento-da-hanseniase" + assert slugify_title(" Olá Mundo! ") == "ola-mundo" + + +def test_slug_base_from_title_uses_uuid_when_only_non_latin(): + base = slug_base_from_title("你好") + assert base.startswith("artigo-") + assert len(base) > len("artigo-") + + +def test_unique_slug_adds_numeric_suffix(): + existing = ["tratamento-da-hanseniase", "tratamento-da-hanseniase-2"] + assert unique_slug("tratamento-da-hanseniase", existing) == "tratamento-da-hanseniase-3" + + +def test_reading_time_ceil_words_per_200(): + content = " ".join(["palavra"] * 201) + assert ArticleService.calculate_reading_time_min(content) == math.ceil(201 / 200) + + assert ArticleService.calculate_reading_time_min("") == 0 + + +def test_article_category_enum_values(): + assert set(ArticleCategory) == { + ArticleCategory.education, + ArticleCategory.news, + ArticleCategory.guidelines, + ArticleCategory.faq, + } diff --git a/docs/milestones/M7-articles.md b/docs/milestones/M7-articles.md index fa0d4f1..5a381df 100644 --- a/docs/milestones/M7-articles.md +++ b/docs/milestones/M7-articles.md @@ -1,6 +1,6 @@ # M7 — Articles -> **Status:** 🔜 Pendente +> **Status:** ✅ Implementado > **Depende de:** M2 > **Bloqueado por:** — @@ -50,7 +50,7 @@ article_tag_associations ← N:M | Router | `routers/article.py` | | Tests | `tests/unit/test_article_schema.py`, `tests/integration/test_article_flow.py` | | Bruno | `bruno/articles/` | -| Migration | `alembic/versions/007_create_articles.py` | +| Migration | `alembic/versions/008_create_articles.py` | ## Endpoints From bbebc05974fed4e5a3814581f9b30a4c8ecfe09d Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 11:26:15 -0300 Subject: [PATCH 23/69] =?UTF-8?q?PEQ-132:=20Inicia=20configura=C3=A7=C3=A3?= =?UTF-8?q?o=20do=20Nginx=20par=20deploy=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(nginx): Add production-ready Docker Compose and Nginx configuration * build(nginx): Add nginx/certs/ to .gitignore Co-authored-by: Rafael Luciano --- .gitignore | 1 + backend/.env.example | 40 +++++++++++ backend/docker-compose.prod.yml | 117 ++++++++++++++++++++++++++++++++ backend/nginx/nginx.conf | 40 +++++++++++ 4 files changed, 198 insertions(+) create mode 100644 backend/docker-compose.prod.yml create mode 100644 backend/nginx/nginx.conf diff --git a/.gitignore b/.gitignore index 1d08d9d..fedbf9b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ env/ .env .env.* !.env.example +nginx/certs/ # uv / pip *.egg-info/ diff --git a/backend/.env.example b/backend/.env.example index 7b7eb11..4182b2e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,43 @@ +# Example environment variables for docker-compose.prod.yml +# Copy this to .env on the production server and fill real values. + +# Docker image tag for the built API/worker/migrate image +IMAGE_TAG=pequi:latest + +# Database connection used by the app and migrate service +# Format: postgresql+asyncpg://user:password@host:5432/dbname +DATABASE_URL=postgresql+asyncpg://pequi:pequi@db:5432/pequi + +# Postgres container user/password/db (used by DB service env) +POSTGRES_USER=pequi +POSTGRES_PASSWORD=pequi +POSTGRES_DB=pequi + +# Redis connection URL +REDIS_URL=redis://redis:6379/0 + +# Application secret key (use a long random value) +SECRET_KEY=replace-with-a-secure-random-string + +# Environment name: production/staging +ENV=production + +# Object storage (MinIO/S3) settings +STORAGE_ENDPOINT=http://minio:9000 +STORAGE_ACCESS_KEY=minioadmin +STORAGE_SECRET_KEY=minioadmin +STORAGE_BUCKET_IMAGES=pequi-images +STORAGE_REGION=us-east-1 + +# MinIO root credentials for the MinIO service (if using MinIO) +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin + +# Sentry DSN for error reporting (empty to disable) +SENTRY_DSN= + +# CORS allowed origins JSON (example): ["https://app.example.com"] +ALLOWED_ORIGINS='["https://yourapp.example.com"]' # ── Aplicação ───────────────────────────────────────────────────────────────── ENV=development # development | staging | production SECRET_KEY=change-me-in-production # chave aleatória de 64+ caracteres diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml new file mode 100644 index 0000000..4b2af60 --- /dev/null +++ b/backend/docker-compose.prod.yml @@ -0,0 +1,117 @@ +version: '3.8' +services: + migrate: + image: ${IMAGE_TAG} + restart: always + environment: + DATABASE_URL: ${DATABASE_URL} + SECRET_KEY: ${SECRET_KEY} + ENV: ${ENV} + command: alembic upgrade head + depends_on: + db: + condition: service_healthy + + api: + image: ${IMAGE_TAG} + restart: always + ports: + - "8000:8000" + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT} + STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} + STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} + STORAGE_BUCKET_IMAGES: ${STORAGE_BUCKET_IMAGES} + STORAGE_REGION: ${STORAGE_REGION} + SECRET_KEY: ${SECRET_KEY} + ENV: ${ENV} + SENTRY_DSN: ${SENTRY_DSN} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + command: uvicorn pequi.main:app --host 0.0.0.0 --port 8000 + + worker: + image: ${IMAGE_TAG} + restart: always + command: python -m arq pequi.workers.settings.WorkerSettings + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT} + STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} + STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} + STORAGE_BUCKET_IMAGES: ${STORAGE_BUCKET_IMAGES} + STORAGE_REGION: ${STORAGE_REGION} + SECRET_KEY: ${SECRET_KEY} + ENV: ${ENV} + SENTRY_DSN: ${SENTRY_DSN} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + db: + image: postgis/postgis:16-3.4 + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - pgdata:/var/lib/postgresql/data + - ./db-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: always + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio + restart: always + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + + nginx: + image: nginx:alpine + restart: always + depends_on: + - api + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro + +volumes: + pgdata: + redisdata: + miniodata: diff --git a/backend/nginx/nginx.conf b/backend/nginx/nginx.conf new file mode 100644 index 0000000..86cbdd6 --- /dev/null +++ b/backend/nginx/nginx.conf @@ -0,0 +1,40 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + + server { + listen 80; + server_name _; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + } +} From e8b9aada8e0599f5f75a3bebfb053846c94b4155 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 12:02:57 -0300 Subject: [PATCH 24/69] PEQ-132: Adiciona staging docker (#34) * build(nginx): Add production-ready Docker Compose and Nginx configuration * build(nginx): Add nginx/certs/ to .gitignore * build(staging): Add staging environment configuration for Docker and Nginx --- backend/.env.example | 23 +++++ backend/docker-compose.staging.yml | 134 +++++++++++++++++++++++++++++ backend/nginx/nginx.staging.conf | 40 +++++++++ 3 files changed, 197 insertions(+) create mode 100644 backend/docker-compose.staging.yml create mode 100644 backend/nginx/nginx.staging.conf diff --git a/backend/.env.example b/backend/.env.example index 4182b2e..6bff3ce 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -74,3 +74,26 @@ ANTHROPIC_MODEL=claude-3-5-haiku-20241022 # ── Sentry ───────────────────────────────────────────────────────────────────── SENTRY_DSN= # vazio em development/test + +# ── Staging overrides (exemplo) ─────────────────────────────────────────────── +# Copy this block to a file named `.env.staging` on the staging host and update +# the values accordingly. These are fictitious example values. +# +# ENV=staging +# SECRET_KEY=replace-with-a-secure-random-string-for-staging +# IMAGE_TAG=pequi:staging +# DATABASE_URL=postgresql+asyncpg://pequi:pequi@db_staging:5432/pequi_staging +# POSTGRES_USER=pequi +# POSTGRES_PASSWORD=pequi +# POSTGRES_DB=pequi_staging +# REDIS_URL=redis://redis_staging:6379/0 +# STORAGE_ENDPOINT=http://minio_staging:9000 +# STORAGE_ACCESS_KEY=minioadmin +# STORAGE_SECRET_KEY=minioadmin +# STORAGE_BUCKET_IMAGES=pequi-images-staging +# STORAGE_REGION=us-east-1 +# MINIO_ROOT_USER=minioadmin +# MINIO_ROOT_PASSWORD=minioadmin +# SENTRY_DSN= +# ALLOWED_ORIGINS='["https://staging.yourapp.example.com"]' + diff --git a/backend/docker-compose.staging.yml b/backend/docker-compose.staging.yml new file mode 100644 index 0000000..3e02958 --- /dev/null +++ b/backend/docker-compose.staging.yml @@ -0,0 +1,134 @@ +version: '3.8' +services: + migrate: + image: ${IMAGE_TAG} + container_name: migrate_staging + restart: always + env_file: + - .env.staging + environment: + DATABASE_URL: ${DATABASE_URL} + SECRET_KEY: ${SECRET_KEY} + ENV: ${ENV} + command: alembic upgrade head + depends_on: + db: + condition: service_healthy + + api: + image: ${IMAGE_TAG} + container_name: api_staging + restart: always + env_file: + - .env.staging + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT} + STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} + STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} + STORAGE_BUCKET_IMAGES: ${STORAGE_BUCKET_IMAGES} + STORAGE_REGION: ${STORAGE_REGION} + SECRET_KEY: ${SECRET_KEY} + ENV: ${ENV} + SENTRY_DSN: ${SENTRY_DSN} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + command: uvicorn pequi.main:app --host 0.0.0.0 --port 8001 + + worker: + image: ${IMAGE_TAG} + container_name: worker_staging + restart: always + env_file: + - .env.staging + command: python -m arq pequi.workers.settings.WorkerSettings + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT} + STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} + STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} + STORAGE_BUCKET_IMAGES: ${STORAGE_BUCKET_IMAGES} + STORAGE_REGION: ${STORAGE_REGION} + SECRET_KEY: ${SECRET_KEY} + ENV: ${ENV} + SENTRY_DSN: ${SENTRY_DSN} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + db: + image: postgis/postgis:16-3.4 + container_name: db_staging + restart: always + env_file: + - .env.staging + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - pgdata_staging:/var/lib/postgresql/data + - ./db-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: redis_staging + restart: always + env_file: + - .env.staging + volumes: + - redisdata_staging:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio + container_name: minio_staging + restart: always + env_file: + - .env.staging + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + ports: + - "9000:9000" + - "9002:9001" + volumes: + - miniodata_staging:/data + + nginx: + image: nginx:alpine + container_name: nginx_staging + restart: always + depends_on: + - api + ports: + - "8080:8080" + - "8443:8443" + volumes: + - ./nginx/nginx.staging.conf:/etc/nginx/nginx.conf:ro + - ./nginx/certs/staging:/etc/nginx/certs:ro + +volumes: + pgdata_staging: + redisdata_staging: + miniodata_staging: diff --git a/backend/nginx/nginx.staging.conf b/backend/nginx/nginx.staging.conf new file mode 100644 index 0000000..927d3ab --- /dev/null +++ b/backend/nginx/nginx.staging.conf @@ -0,0 +1,40 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + + server { + listen 8080; + server_name _; + return 301 https://$host$request_uri; + } + + server { + listen 8443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/staging/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/staging/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://api_staging:8001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + } +} From f45e1dcba3c6763b188e03b07c98f0f7192416d8 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 14:57:21 -0300 Subject: [PATCH 25/69] PEQ-132: Cria ambiente de deploy para AWS (#35) * build(nginx): Add production-ready Docker Compose and Nginx configuration * build(nginx): Add nginx/certs/ to .gitignore * build(staging): Add staging environment configuration for Docker and Nginx * build(deploy): Add GitHub Actions workflow for staging and production deployments --- .github/workflows/deploy.yml | 204 +++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..52c02ec --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,204 @@ +name: Deploy + +on: + push: + branches: + - development + - main + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + # ───────────────────────────────────────────── + # Staging — dispara apenas em push na development + # ───────────────────────────────────────────── + staging: + name: Deploy → Staging + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/development' + environment: staging + + permissions: + contents: read + id-token: write + + env: + IMAGE_TAG: ${{ secrets.ECR_REGISTRY }}/${{ secrets.ECR_REPOSITORY }}:staging-${{ github.sha }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push image to ECR (staging) + working-directory: backend + run: | + docker build -t "$IMAGE_TAG" . + docker push "$IMAGE_TAG" + + - name: Deploy to EC2 via SSH (staging) + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: IMAGE_TAG,ECR_REGISTRY,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY + script: | + set -euo pipefail + + cd ~/pequi + + git pull origin development + + aws ecr get-login-password --region us-east-2 \ + | docker login --username AWS --password-stdin "$ECR_REGISTRY" + + docker pull "$IMAGE_TAG" + + IMAGE_TAG="$IMAGE_TAG" \ + docker compose -f docker-compose.staging.yml up -d --no-build + + docker compose -f docker-compose.staging.yml exec -T migrate \ + alembic upgrade head + + docker image prune -f + env: + ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Health check — Staging + id: healthcheck + run: | + echo "Aguardando API inicializar..." + sleep 15 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time 30 --retry 5 --retry-delay 10 \ + "http://${{ secrets.EC2_HOST }}:8080/docs") + echo "HTTP status: $STATUS" + if [ "$STATUS" != "200" ]; then + echo "::error::Health check falhou — HTTP $STATUS" + exit 1 + fi + + - name: Exibir logs do container api (apenas em falha) + if: failure() && steps.healthcheck.outcome == 'failure' + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd ~/pequi + echo "=== Logs do container api (últimas 100 linhas) ===" + docker compose -f docker-compose.staging.yml logs --tail=100 api + + # ───────────────────────────────────────────── + # Production — dispara apenas em push na main + # ───────────────────────────────────────────── + production: + name: Deploy → Production + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + environment: production + + permissions: + contents: read + id-token: write + + env: + IMAGE_TAG: ${{ secrets.ECR_REGISTRY }}/${{ secrets.ECR_REPOSITORY }}:${{ github.sha }} + IMAGE_TAG_LATEST: ${{ secrets.ECR_REGISTRY }}/${{ secrets.ECR_REPOSITORY }}:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push image to ECR (production) + working-directory: backend + run: | + docker build -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" . + docker push "$IMAGE_TAG" + docker push "$IMAGE_TAG_LATEST" + + - name: Deploy to EC2 via SSH (production) + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: IMAGE_TAG,ECR_REGISTRY,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY + script: | + set -euo pipefail + + cd ~/pequi + + git pull origin main + + aws ecr get-login-password --region us-east-2 \ + | docker login --username AWS --password-stdin "$ECR_REGISTRY" + + docker pull "$IMAGE_TAG" + + IMAGE_TAG="$IMAGE_TAG" \ + docker compose -f docker-compose.prod.yml up -d --no-build + + docker compose -f docker-compose.prod.yml exec -T migrate \ + alembic upgrade head + + docker image prune -f + env: + ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Health check — Production + id: healthcheck + run: | + echo "Aguardando API inicializar..." + sleep 15 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time 30 --retry 5 --retry-delay 10 \ + "http://${{ secrets.EC2_HOST }}/docs") + echo "HTTP status: $STATUS" + if [ "$STATUS" != "200" ]; then + echo "::error::Health check falhou — HTTP $STATUS" + exit 1 + fi + + - name: Exibir logs do container api (apenas em falha) + if: failure() && steps.healthcheck.outcome == 'failure' + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd ~/pequi + echo "=== Logs do container api (últimas 100 linhas) ===" + docker compose -f docker-compose.prod.yml logs --tail=100 api From cdc5221a59bec95cabcdd31fe917af3f5678970b Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 15:15:29 -0300 Subject: [PATCH 26/69] ci: trigger first staging deploy (#36) Co-authored-by: Rafael Luciano From e1951759e0f60d84940bfee0405a12253255fc3e Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 15:24:19 -0300 Subject: [PATCH 27/69] ci(0.0.2-rc-1): fix ssh key and security group (#38) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group Co-authored-by: Rafael Luciano From 4706ffadddabbf565f07d227fd593ad77f582891 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 15:31:24 -0300 Subject: [PATCH 28/69] ci(0.0.2-rc-1): fix backend directory (#39) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory --- .github/PR_BODY_PEQ-132.md | 105 ++++++++++ .github/workflows/deploy.yml | 4 +- PR_BODY_PEQ_80.md | 394 +++++++++++++++++++++++++++++++++++ review.md | 11 + 4 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 .github/PR_BODY_PEQ-132.md create mode 100644 PR_BODY_PEQ_80.md create mode 100644 review.md diff --git a/.github/PR_BODY_PEQ-132.md b/.github/PR_BODY_PEQ-132.md new file mode 100644 index 0000000..2bd6bb3 --- /dev/null +++ b/.github/PR_BODY_PEQ-132.md @@ -0,0 +1,105 @@ +# Pull Request — PEQ-132 | 28-05-2026 + +## Descrição + +Este PR atende a tarefa **[PEQ-132](https://pequi-pds-team.atlassian.net/browse/PEQ-132)**. Resumo das alterações e objetivo do PR: realizar configuração do deploy. + +--- + +## Funcionalidades + +- **Resumo:** início do deploy integral. + + +--- + +## Melhorias de TUI / UX + +- Descrever aqui qualquer melhoria visível para o usuário (front-end, mensagens de erro, contratos Bruno) ou indicar "Não aplicável". + +--- + +## Lógica de Prioridade + +--- + +## Ajustes + + +--- + +## Integração + +- Jira: [PEQ-132](https://pequi-pds-team.atlassian.net/browse/PEQ-132) + +--- + +## Observações + +- Pontos conhecidos e limitações (ex.: fluxo de autenticação stubbed, dependências de outro PR, necessidade de ajustes manuais em migrações). +- Instruções rápidas para testar localmente (ex.: como rodar a suíte ou endpoints principais). Exemplo: + +```bash +cd backend +source .venv/bin/activate +scripts/run_tests.sh +``` + +### Configuração de Deploy / Nginx / Cloudflare / SSL + +- **Último commit:** ajustes no arquivo de configuração do servidor reverso em `nginx/nginx.conf` para compatibilizar com o deploy do frontend e proxy reverso. Verifique esse arquivo ao revisar o PR. +- **Objetivo:** expor o ambiente de deploy do frontend via domínio/subdomínio com Cloudflare, garantindo HTTPS válido entre o usuário e o frontend (e entre Cloudflare e a origem, se aplicável). + +Passos recomendados para configurar o domínio com Cloudflare: + +1. No painel do Cloudflare, adicione o domínio ou subdomínio que irá apontar para o deploy do frontend (ex.: `app.example.com`). +2. Crie o DNS record apropriado: + - Se o provedor de hosting do frontend fornecer um hostname (Netlify / Vercel / Cloudflare Pages), crie um `CNAME` apontando para esse hostname. + - Se for necessário apontar para um IP, crie um `A` record para o IP público do servidor onde o `nginx` está configurado. +3. Ative o proxy do Cloudflare (nuvem laranja) para tirar vantagem do CDN e WAF, a menos que haja necessidade explícita de bypass. +4. SSL/TLS: + - Recomenda-se usar o modo `Full (strict)` no Cloudflare. + - Para `Full (strict)`, gere um certificado de origem no Cloudflare (Origin Certificate) e instale o `cert` e `key` no servidor de origem onde o `nginx` roda. Configure `nginx` para usar esses arquivos como `ssl_certificate` / `ssl_certificate_key`. + - Se não puder usar certificados de origem, use um certificado válido emitido por uma CA pública (Let's Encrypt, etc.) e configure o mesmo em `nginx`. +5. Segurança TLS: + - Habilite TLS 1.2+ e HTTP/2. Considere HSTS se já tiver confiança na configuração. +6. Cache e invalidação: + - Configure as regras de cache no Cloudflare conforme as necessidades do frontend. + - Após deploy, faça purge (invalidação) do cache do Cloudflare para servir a versão nova. +7. Testes e verificação: + - Valide HTTPS: `curl -I https://app.example.com` e verifique certificado e `200`. + - Verifique cabeçalhos `CF-` e que o tráfego passa pelo Cloudflare. + +Exemplo mínimo de trecho `nginx` (adaptar conforme sua configuração): + +``` +server { + listen 443 ssl http2; + server_name app.example.com; + + ssl_certificate /etc/ssl/certs/cloudflare-origin.pem; + ssl_certificate_key /etc/ssl/private/cloudflare-origin-key.pem; + + location / { + proxy_pass http://frontend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Observação: adapte os caminhos de certificado e o `proxy_pass` ao seu ambiente. Se o deploy do frontend for em plataforma serverless (ex.: Cloudflare Pages, Vercel, Netlify), prefira apontar com `CNAME` diretamente para o host fornecido pelo provedor e use as funcionalidades de SSL gerenciadas pelo provedor/Cloudflare. + + +--- + +## Checklist + +- [ ] O código compila sem erros +- [ ] Testes foram adicionados ou atualizados +- [ ] A documentação foi atualizada +- [ ] Revisado por pelo menos um membro da equipe + +--- \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52c02ec..0fd1cfe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,7 +58,7 @@ jobs: script: | set -euo pipefail - cd ~/pequi + cd ~/pequi/backend git pull origin development @@ -155,7 +155,7 @@ jobs: script: | set -euo pipefail - cd ~/pequi + cd ~/pequi/backend git pull origin main diff --git a/PR_BODY_PEQ_80.md b/PR_BODY_PEQ_80.md new file mode 100644 index 0000000..932e653 --- /dev/null +++ b/PR_BODY_PEQ_80.md @@ -0,0 +1,394 @@ +# Pull Request — PEQ-80 | 26-05-2026 + +## Descrição + +Este PR implementa a milestone **M5 — Body Map**, introduzindo suporte completo ao mapa corporal interativo para pacientes, incluindo: + +* marcação de lesões e alterações sensitivas +* histórico imutável de evolução clínica +* upload preparado para object storage +* snapshots automáticos durante check-ins +* isolamento multi-tenant para profissionais + +A implementação segue a arquitetura existente do projeto, mantendo separação clara entre: + +* routers +* use cases +* repositories +* services + +--- + +## Funcionalidades + +### Mapa Corporal Atual + +Implementado suporte ao estado atual do mapa corporal do paciente através de: + +* `BodyMapEntry` +* upsert por `(patient_id, body_area_id)` +* soft delete via `deleted_at` +* exclusão automática de registros removidos nas consultas + +Endpoints: + +* `GET /v1/body-map` +* `PUT /v1/body-map` + +--- + +### Catálogo de Áreas Corporais + +Adicionado catálogo fixo de regiões corporais: + +* `BodyArea` +* enums tipados: + + * `BodySide` + * `BodySystemPart` + +Inclui: + +* 26 áreas corporais iniciais +* organização por: + + * cabeça + * tronco + * membros superiores + * membros inferiores + +Endpoint: + +* `GET /v1/body-areas` + +--- + +### Histórico Imutável + +Implementado sistema append-only de snapshots clínicos: + +* `BodyAreaHistory` +* snapshots nunca sofrem UPDATE/DELETE +* ordenação por `snapshot_at DESC` +* filtros por: + + * `body_area_id` + * `finding_type` + * intervalo de datas + +Endpoint: + +* `GET /v1/body-map/history` + +--- + +### Snapshot Automático no Check-in + +Integrado fluxo automático de snapshot ao submit de check-in (M4): + +* snapshot criado ao finalizar check-in +* cópia do estado atual do body map +* helper reutilizável: + + * `create_body_map_snapshot(...)` + +--- + +### Upload Preparado para MinIO/R2 + +Criada abstração de storage para futura integração M10: + +* `StorageService` +* `FakeStorageService` + +O sistema: + +* NÃO armazena bytes no PostgreSQL +* salva apenas: + + * `image_url` + * `image_key` + +Endpoint: + +* `POST /v1/body-map/upload` + +--- + +## Modelagem + Migração + +### Novos Models + +Arquivo: + +* `backend/src/pequi/models/body_map.py` + +Models adicionados: + +* `BodyArea` +* `BodyMapEntry` +* `BodyAreaHistory` + +Inclui: + +* enums tipados +* soft delete +* índices de performance +* unique parcial para impedir múltiplas entradas ativas por área/paciente + +--- + +### Migração Alembic + +Arquivo: + +* `backend/alembic/versions/006_create_body_map.py` + +Responsável por: + +* criação de enums +* criação de tabelas +* índices +* constraints +* seed inicial das áreas corporais + +Observação: + +* a migração foi criada como `006_create_body_map.py` +* mantém compatibilidade com `005_create_checkins.py` + +--- + +## Schemas (Pydantic v2) + +Arquivo: + +* `backend/src/pequi/schemas/body_map.py` + +Schemas adicionados: + +* `BodyAreaResponse` +* `BodyMapEntryCreate` +* `BodyMapEntryUpdate` +* `BodyMapEntryResponse` +* `BodyMapHistoryResponse` +* `UploadUrlResponse` +* `BodyMapUploadRequest` +* `BodyMapUpdateRequest` + +Validações implementadas: + +* intensidade entre `0..3` +* enums tipados +* limites de tamanho em strings +* sanitização simples +* `finding_type` obrigatório quando `remove=false` + +--- + +## Repository Layer + +Arquivo: + +* `backend/src/pequi/repositories/body_map_repo.py` + +Responsabilidades: + +* leitura do mapa atual +* upsert transacional +* soft delete +* snapshots históricos +* filtros de histórico +* catálogo de áreas + +Queries otimizadas para: + +* evitar N+1 +* excluir soft deleted +* ordenar snapshots eficientemente + +--- + +## Use Cases + +### Arquivos + +* `backend/src/pequi/use_cases/update_body_map.py` +* `backend/src/pequi/use_cases/get_body_map_history.py` + +Use cases implementados: + +* `GetBodyMapUseCase` +* `UpdateBodyMapUseCase` +* `ListBodyAreasUseCase` +* `GenerateBodyMapUploadUrlUseCase` + +Inclui: + +* validação multi-tenant +* logs estruturados +* transações centralizadas +* isolamento de regras de negócio fora dos routers + +--- + +## Segurança Multi-Tenant + +Implementado controle de acesso entre profissionais e pacientes: + +* profissionais só acessam pacientes da mesma unidade +* validação via `health_unit_id` +* proteção centralizada no use case de histórico + +Logs estruturados adicionados para: + +* acessos válidos +* tentativas cross-tenant + +--- + +## Testes + +### Unitários + +Arquivo: + +* `backend/tests/unit/test_body_map_schema.py` + +Cobertura: + +* intensity inválida +* enum inválido +* payload válido + +--- + +### Integração + +Arquivo: + +* `backend/tests/integration/test_body_map.py` + +Cobertura: + +* GET body map +* PUT upsert +* soft delete +* invalid body_area_id → 404 +* histórico e filtros +* upload endpoint +* isolamento multi-tenant +* snapshot via fluxo de check-in + +--- + +## Bruno Collection + +Nova coleção: + +* `backend/bruno/body_map/` + +Inclui: + +* `get_body_map.bru` +* `update_body_map.bru` +* `get_history.bru` +* `list_body_areas.bru` +* `upload_image.bru` + +Exemplos inválidos: + +* `update_body_map_invalid_area.bru` +* `upload_image_invalid_type.bru` + +--- + +## Decisões Arquiteturais + +### Histórico Append-Only + +O histórico clínico foi modelado como append-only: + +* sem endpoints DELETE +* sem endpoints UPDATE +* repository apenas insere snapshots + +Garante: + +* rastreabilidade clínica +* integridade histórica +* auditabilidade + +--- + +### Soft Delete no Estado Atual + +`BodyMapEntry` utiliza: + +* `deleted_at` + +Benefícios: + +* preservação de contexto clínico +* reversibilidade lógica +* consistência com histórico + +--- + +### Storage Abstraction + +A camada de upload foi abstraída desde a M5 para facilitar integração futura com: + +* MinIO +* Cloudflare R2 +* S3-compatible storage + +Sem necessidade de alteração da API pública. + +--- + +### Separação de Responsabilidades + +Mantida separação clara: + +* router → transporte HTTP +* use case → regras/orquestração +* repository → persistência + +Sem lógica de negócio em routers. + +--- + +## Validação Executada + +* `uv run ruff check .` ✅ +* `uv run ruff format --check .` ✅ +* `scripts/run_tests.sh tests/unit/test_body_map_schema.py` ✅ + +Observação: + +* suíte completa de integração bloqueada localmente por ausência de PostgreSQL ativo (`ConnectionRefusedError em 127.0.0.1:5432`) + +--- + +## Melhorias Futuras (M10) + +Planejado para integração real com object storage: + +* assinatura real de upload URL +* expiração de URLs +* enforcement de MIME/type +* limite de tamanho por tenant +* antivírus assíncrono +* persistência de metadados +* segregação de bucket/prefix por tenant +* políticas LGPD de retenção e anonimização + +--- + +## Checklist + +* [x] O código compila sem erros +* [x] Testes foram adicionados ou atualizados +* [x] A documentação foi atualizada +* [ ] Revisado por pelo menos um membro da equipe diff --git a/review.md b/review.md new file mode 100644 index 0000000..8ab809c --- /dev/null +++ b/review.md @@ -0,0 +1,11 @@ +## Bateria de testes + +### Erro da classe `op.bulk_insert` encontrado durante a execução do workflow de testes + +**Causa raiz:** op.bulk_insert vincula parâmetros via psycopg com tipos derivados da definição da tabela auxiliar (sa.column("side", sa.Text())), gerando $4::VARCHAR. O PostgreSQL 16 em modo estrito não aceita cast implícito de varchar para um tipo ENUM customizado. + +**Correção:** substituído op.bulk_insert por op.execute(sa.text(...)) com os valores embutidos diretamente no SQL literal. Quando os valores aparecem como literais de string no SQL (não como parâmetros vinculados), o PostgreSQL resolve a conversão implicitamente para o ENUM declarado na coluna — comportamento padrão e confiável para seeds em migrations. + +**Efeito colateral:** remoção do import uuid e da função helper _row que ficaram órfãos. + +**Lint:** adicionado per-file-ignores para `alembic/versions/*.py` ignorando E501 — migrations frequentemente têm SQL literal com linhas longas e é o padrão correto para esse caso, evitando contorções de formatação no SQL. \ No newline at end of file From 707c562630e79c2d42117fe691acb1e0e1dcfa18 Mon Sep 17 00:00:00 2001 From: Rafael Luciano <74800037+rafaellucian0@users.noreply.github.com> Date: Thu, 28 May 2026 16:16:32 -0300 Subject: [PATCH 29/69] PEQ-84: Implement M9 ARQ workers (#31) * feat: implement ARQ workers with idempotent upsert operations * fix(arq): add unique constraints and fix worker bugs * fix(arq): add unique constraints and fix lint errors * fix(ci): use alembic upgrade heads to handle multiple migrations * fix(ci): use alembic heads instead of head for migration commands * fix(tests): add foreign key dependencies to adherence worker test fixtures * fix(tests): add User creation before PatientProfile to satisfy FK constraint * fix(tests): add required User fields (email, password, name, role) to test fixtures * fix(tests): add HealthUnit creation before PatientProfile to satisfy FK constraint * fix(tests): add HealthProfessional creation before Treatment to satisfy FK constraint * fix(tests): flush treatment before dose logs and fix upsert_snapshot constraint * fix(tests): use excluded values in upsert and inject db_session in adherence_job context * fix(tests): use manual upsert in adherence_repo and add summary_worker tests for coverage * fix: unused variable and unsorted imports in test_summary_worker * fix(tests): use correct class name WeeklySymptomSummary in test_summary_worker * fix(tests): fix mock signatures in test_summary_worker * fix(tests): use valid enum value 'ok' instead of 'neutral' in test_summary_worker * fix(tests): add active treatment to patient in test_summary_job_processes_active_patients * fix(tests): use checked_in_at instead of created_at in test_summary_worker * fix(tests): fix summary_worker test and refactor weekly_summary imports and week calculation * fix: remove unused imports and fix long line in summary_worker * fix(tests): remove datetime.now() mock and use real time in test_summary_worker * fix(tests): calculate week boundaries same way as worker in test_summary_worker * lint: fix long lines in test_summary_worker * lint: fix remaining long lines in test_summary_worker * fix: address all code review comments from PR #31 - Renumber Alembic migrations to 010, 011, 012 to avoid conflict with development - Remove _ensure_alembic_version_table workaround from env.py - Fix AdherenceRepository.upsert_snapshot race condition with INSERT ON CONFLICT - Fix week calculation to Sunday-Saturday (not Monday-Saturday) - Apply notifications_enabled to ai_feedback in notification_worker - Move integration tests from tests/unit/ to tests/integration/ - Extract test setup helper to eliminate duplication - Add feedback_content parameter to ArqJobEnqueuer.enqueue_notification - Restore alembic upgrade head (singular) in CI workflow - Update test_summary_worker to use correct week calculation * fix: correct down_revision for 010_weekly_summaries to 006_create_body_map * fix: renumber migrations to 100, 101, 102 to avoid conflict with development * fix: restore alembic upgrade heads (plural) in CI to handle multiple heads * fix: correct upsert_snapshot to use constraint name and fix test duplicate key * fix: use insert().excluded for upsert and fix foreign key in tests * fix: add commit in upsert test and mark existing treatment as completed * fix: move update import to top and break long line for lint * fix: remove returning clause from upsert and select separately to get updated value * fix: use select-then-update-or-insert approach for upsert_snapshot * fix: remove unused import sqlalchemy.dialects.postgresql.insert * fix(arq): linearize alembic chain and atomic upsert_snapshot - Set down_revision of 100_weekly_summaries to 009_create_audit_logs, eliminating the parallel branch from 006_create_body_map - Replace select-then-insert upsert_snapshot with INSERT ON CONFLICT DO UPDATE to prevent race condition under concurrent worker execution - Restore alembic upgrade head (singular) in CI workflow - Remove debug ci-*-logs*.txt files accidentally committed Co-authored-by: Cursor * fix(arq): refresh snapshot after upsert to bypass SQLAlchemy identity map cache Co-authored-by: Cursor --------- Co-authored-by: Matheus Ryan Co-authored-by: Cursor --- .../alembic/versions/100_weekly_summaries.py | 68 ++++ .../versions/101_notifications_enabled.py | 29 ++ .../versions/102_unique_constraints.py | 40 +++ backend/src/pequi/models/__init__.py | 2 + backend/src/pequi/models/dose_log.py | 6 + backend/src/pequi/models/patient.py | 3 +- backend/src/pequi/models/weekly_summary.py | 49 +++ .../src/pequi/repositories/adherence_repo.py | 84 +++++ .../pequi/repositories/weekly_summary_repo.py | 106 ++++++ .../pequi/services/notification_service.py | 21 ++ backend/src/pequi/workers/adherence_worker.py | 94 ++++++ .../src/pequi/workers/ai_feedback_worker.py | 30 +- backend/src/pequi/workers/job_enqueue.py | 32 +- .../src/pequi/workers/notification_worker.py | 95 ++++++ backend/src/pequi/workers/settings.py | 18 +- backend/src/pequi/workers/summary_worker.py | 84 +++++ .../integration/test_adherence_worker.py | 318 ++++++++++++++++++ .../tests/integration/test_summary_worker.py | 171 ++++++++++ 18 files changed, 1241 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/100_weekly_summaries.py create mode 100644 backend/alembic/versions/101_notifications_enabled.py create mode 100644 backend/alembic/versions/102_unique_constraints.py create mode 100644 backend/src/pequi/models/weekly_summary.py create mode 100644 backend/src/pequi/repositories/adherence_repo.py create mode 100644 backend/src/pequi/repositories/weekly_summary_repo.py create mode 100644 backend/src/pequi/workers/adherence_worker.py create mode 100644 backend/src/pequi/workers/notification_worker.py create mode 100644 backend/src/pequi/workers/summary_worker.py create mode 100644 backend/tests/integration/test_adherence_worker.py create mode 100644 backend/tests/integration/test_summary_worker.py diff --git a/backend/alembic/versions/100_weekly_summaries.py b/backend/alembic/versions/100_weekly_summaries.py new file mode 100644 index 0000000..bce870d --- /dev/null +++ b/backend/alembic/versions/100_weekly_summaries.py @@ -0,0 +1,68 @@ +"""create weekly symptom summaries + +Revision ID: 100_weekly_summaries +Revises: 009_create_audit_logs +Create Date: 2026-05-28 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "100_weekly_summaries" +down_revision: str | None = "009_create_audit_logs" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "weekly_symptom_summaries", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("week_start", sa.Date(), nullable=False), + sa.Column("week_end", sa.Date(), nullable=False), + sa.Column("avg_intensity", sa.SmallInteger(), nullable=True), + sa.Column("dominant_mood", sa.Text(), nullable=True), + sa.Column("checkin_count", sa.SmallInteger(), nullable=False), + sa.Column("alert_count", sa.SmallInteger(), nullable=False), + sa.Column( + "calculated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["patient_id"], ["patient_profiles.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_weekly_symptom_summaries_patient_id", + "weekly_symptom_summaries", + ["patient_id"], + unique=False, + ) + op.create_index( + "ix_weekly_symptom_summaries_week_start", + "weekly_symptom_summaries", + ["week_start"], + unique=False, + ) + op.create_index( + "ix_weekly_symptom_summaries_calculated_at", + "weekly_symptom_summaries", + ["calculated_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_weekly_symptom_summaries_calculated_at", table_name="weekly_symptom_summaries" + ) + op.drop_index("ix_weekly_symptom_summaries_week_start", table_name="weekly_symptom_summaries") + op.drop_index("ix_weekly_symptom_summaries_patient_id", table_name="weekly_symptom_summaries") + op.drop_table("weekly_symptom_summaries") diff --git a/backend/alembic/versions/101_notifications_enabled.py b/backend/alembic/versions/101_notifications_enabled.py new file mode 100644 index 0000000..ee1c101 --- /dev/null +++ b/backend/alembic/versions/101_notifications_enabled.py @@ -0,0 +1,29 @@ +"""add notifications_enabled to patient profiles + +Revision ID: 101_notifications_enabled +Revises: 100_weekly_summaries +Create Date: 2026-05-28 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "101_notifications_enabled" +down_revision: str | None = "100_weekly_summaries" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "patient_profiles", + sa.Column("notifications_enabled", sa.Boolean(), server_default="true", nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("patient_profiles", "notifications_enabled") diff --git a/backend/alembic/versions/102_unique_constraints.py b/backend/alembic/versions/102_unique_constraints.py new file mode 100644 index 0000000..f5c1c12 --- /dev/null +++ b/backend/alembic/versions/102_unique_constraints.py @@ -0,0 +1,40 @@ +"""add unique constraints for upsert operations + +Revision ID: 102_unique_constraints +Revises: 101_notifications_enabled +Create Date: 2026-05-28 00:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "102_unique_constraints" +down_revision: str | None = "101_notifications_enabled" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Add unique constraint for adherence_snapshots + op.create_unique_constraint( + "uq_adherence_snapshots_period", + "adherence_snapshots", + ["treatment_id", "period_start", "period_end"], + ) + + # Add unique constraint for weekly_symptom_summaries + op.create_unique_constraint( + "uq_weekly_symptom_summaries_period", + "weekly_symptom_summaries", + ["patient_id", "week_start"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "uq_weekly_symptom_summaries_period", "weekly_symptom_summaries", type_="unique" + ) + op.drop_constraint("uq_adherence_snapshots_period", "adherence_snapshots", type_="unique") diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 8be4197..f2b5df3 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -17,6 +17,7 @@ from pequi.models.symptom import Symptom from pequi.models.treatment import DoseSchedule, Treatment from pequi.models.user import User +from pequi.models.weekly_summary import WeeklySymptomSummary __all__ = [ "AdherenceSnapshot", @@ -42,4 +43,5 @@ "Symptom", "Treatment", "User", + "WeeklySymptomSummary", ] diff --git a/backend/src/pequi/models/dose_log.py b/backend/src/pequi/models/dose_log.py index db24a32..f53e152 100644 --- a/backend/src/pequi/models/dose_log.py +++ b/backend/src/pequi/models/dose_log.py @@ -66,6 +66,12 @@ class AdherenceSnapshot(Base): __tablename__ = "adherence_snapshots" __table_args__ = ( + UniqueConstraint( + "treatment_id", + "period_start", + "period_end", + name="uq_adherence_snapshots_period", + ), Index("ix_adherence_snapshots_treatment_id", "treatment_id"), Index("ix_adherence_snapshots_patient_id", "patient_id"), Index("ix_adherence_snapshots_calculated_at", "calculated_at"), diff --git a/backend/src/pequi/models/patient.py b/backend/src/pequi/models/patient.py index a027061..014c0c5 100644 --- a/backend/src/pequi/models/patient.py +++ b/backend/src/pequi/models/patient.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.sql import func @@ -29,6 +29,7 @@ class PatientProfile(Base): disability_grade = Column(Integer, default=0) diagnosis_date = Column(Date) classification = Column(String(10)) + notifications_enabled = Column(Boolean, server_default="true", nullable=False, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column( DateTime(timezone=True), diff --git a/backend/src/pequi/models/weekly_summary.py b/backend/src/pequi/models/weekly_summary.py new file mode 100644 index 0000000..574bf9f --- /dev/null +++ b/backend/src/pequi/models/weekly_summary.py @@ -0,0 +1,49 @@ +import uuid + +from sqlalchemy import ( + Column, + Date, + DateTime, + ForeignKey, + Index, + SmallInteger, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class WeeklySymptomSummary(Base): + """Resumo semanal de sintomas — calculado exclusivamente pelo worker (M9). + + Nunca recalculado em tempo real. Os endpoints leem apenas desta tabela. + """ + + __tablename__ = "weekly_symptom_summaries" + __table_args__ = ( + UniqueConstraint( + "patient_id", + "week_start", + name="uq_weekly_symptom_summaries_period", + ), + Index("ix_weekly_symptom_summaries_patient_id", "patient_id"), + Index("ix_weekly_symptom_summaries_week_start", "week_start"), + Index("ix_weekly_symptom_summaries_calculated_at", "calculated_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + week_start = Column(Date, nullable=False) + week_end = Column(Date, nullable=False) + avg_intensity = Column(SmallInteger, nullable=True) + dominant_mood = Column(Text, nullable=True) + checkin_count = Column(SmallInteger, nullable=False) + alert_count = Column(SmallInteger, nullable=False) + calculated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/src/pequi/repositories/adherence_repo.py b/backend/src/pequi/repositories/adherence_repo.py new file mode 100644 index 0000000..a5b46bd --- /dev/null +++ b/backend/src/pequi/repositories/adherence_repo.py @@ -0,0 +1,84 @@ +from datetime import UTC, date, datetime +from decimal import Decimal +from uuid import UUID + +from sqlalchemy import and_, func, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.dose_log import AdherenceSnapshot, DoseLog +from pequi.models.treatment import Treatment, TreatmentStatus + + +class AdherenceRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def upsert_snapshot( + self, + patient_id: UUID, + treatment_id: UUID, + period_start: date, + period_end: date, + total_doses: int, + taken_doses: int, + adherence_pct: Decimal, + ) -> AdherenceSnapshot: + """Upsert adherence snapshot (idempotent) via INSERT ON CONFLICT DO UPDATE.""" + stmt = ( + insert(AdherenceSnapshot) + .values( + patient_id=patient_id, + treatment_id=treatment_id, + period_start=period_start, + period_end=period_end, + total_doses=total_doses, + taken_doses=taken_doses, + adherence_pct=adherence_pct, + calculated_at=datetime.now(UTC), + ) + .on_conflict_do_update( + index_elements=["treatment_id", "period_start", "period_end"], + set_={ + "total_doses": total_doses, + "taken_doses": taken_doses, + "adherence_pct": adherence_pct, + "calculated_at": datetime.now(UTC), + }, + ) + .returning(AdherenceSnapshot) + ) + result = await self._session.execute(stmt) + await self._session.flush() + snapshot = result.scalar_one() + # Refresh to bypass the session identity map which may hold stale values + # when the same PK was previously loaded within this session. + await self._session.refresh(snapshot) + return snapshot + + async def get_dose_counts_in_period( + self, + treatment_id: UUID, + period_start: datetime, + period_end: datetime, + ) -> tuple[int, int]: + """Returns (total_doses, taken_doses) for a treatment in a period.""" + stmt = select( + func.count(DoseLog.id).label("total"), + func.count(DoseLog.taken_at).label("taken"), + ).where( + and_( + DoseLog.treatment_id == treatment_id, + DoseLog.expected_at >= period_start, + DoseLog.expected_at <= period_end, + ) + ) + result = await self._session.execute(stmt) + row = result.one() + return (int(row.total), int(row.taken)) + + async def list_active_treatments(self) -> list[Treatment]: + """Returns all active treatments.""" + stmt = select(Treatment).where(Treatment.status == TreatmentStatus.active) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/pequi/repositories/weekly_summary_repo.py b/backend/src/pequi/repositories/weekly_summary_repo.py new file mode 100644 index 0000000..1e43659 --- /dev/null +++ b/backend/src/pequi/repositories/weekly_summary_repo.py @@ -0,0 +1,106 @@ +from datetime import UTC, date, datetime +from uuid import UUID + +from sqlalchemy import and_, func, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.alert import Alert +from pequi.models.checkin import Checkin +from pequi.models.treatment import Treatment, TreatmentStatus +from pequi.models.weekly_summary import WeeklySymptomSummary + + +class WeeklySummaryRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def upsert_summary( + self, + patient_id: UUID, + week_start: date, + week_end: date, + avg_intensity: int | None, + dominant_mood: str | None, + checkin_count: int, + alert_count: int, + ) -> WeeklySymptomSummary: + """Upsert weekly symptom summary (idempotent).""" + stmt = ( + insert(WeeklySymptomSummary) + .values( + patient_id=patient_id, + week_start=week_start, + week_end=week_end, + avg_intensity=avg_intensity, + dominant_mood=dominant_mood, + checkin_count=checkin_count, + alert_count=alert_count, + calculated_at=datetime.now(UTC), + ) + .on_conflict_do_update( + index_elements=["patient_id", "week_start"], + set_={ + "avg_intensity": avg_intensity, + "dominant_mood": dominant_mood, + "checkin_count": checkin_count, + "alert_count": alert_count, + "calculated_at": datetime.now(UTC), + }, + ) + .returning(WeeklySymptomSummary) + ) + result = await self._session.execute(stmt) + await self._session.flush() + return result.scalar_one() + + async def get_weekly_stats( + self, + patient_id: UUID, + week_start: datetime, + week_end: datetime, + ) -> tuple[int | None, str | None, int, int]: + """Returns (avg_intensity, dominant_mood, checkin_count, alert_count) + for a patient in a week. + """ + # Get checkin stats + stmt = select( + func.avg(Checkin.symptom_intensity).label("avg_intensity"), + func.mode().within_group(Checkin.mood).label("dominant_mood"), + func.count(Checkin.id).label("checkin_count"), + ).where( + and_( + Checkin.patient_id == patient_id, + Checkin.checked_in_at >= week_start, + Checkin.checked_in_at < week_end, + ) + ) + result = await self._session.execute(stmt) + row = result.one() + + avg_intensity = int(row.avg_intensity) if row.avg_intensity else None + dominant_mood = row.dominant_mood if row.dominant_mood else None + checkin_count = int(row.checkin_count) + + # Get alert count from alerts table + alert_stmt = select(func.count(Alert.id)).where( + and_( + Alert.patient_id == patient_id, + Alert.created_at >= week_start, + Alert.created_at < week_end, + ) + ) + alert_result = await self._session.execute(alert_stmt) + alert_count = int(alert_result.scalar() or 0) + + return (avg_intensity, dominant_mood, checkin_count, alert_count) + + async def list_active_patients(self) -> list[UUID]: + """Returns all patient IDs with active treatments.""" + stmt = ( + select(Treatment.patient_id) + .where(Treatment.status == TreatmentStatus.active) + .distinct() + ) + result = await self._session.execute(stmt) + return [row[0] for row in result.all()] diff --git a/backend/src/pequi/services/notification_service.py b/backend/src/pequi/services/notification_service.py index f6fe3b8..9a81aa7 100644 --- a/backend/src/pequi/services/notification_service.py +++ b/backend/src/pequi/services/notification_service.py @@ -15,3 +15,24 @@ async def send_feedback(self, patient_id: UUID, feedback: str) -> None: patient_id=str(patient_id), feedback_length=len(feedback), ) + + async def send_dose_reminder(self, patient_id: UUID) -> None: + """Envia lembrete diário de dose ao paciente.""" + logger.info( + "notification.dose_reminder_queued", + patient_id=str(patient_id), + ) + + async def send_low_adherence_alert(self, patient_id: UUID) -> None: + """Envia alerta de baixa adesão para o profissional.""" + logger.info( + "notification.low_adherence_alert_queued", + patient_id=str(patient_id), + ) + + async def send_alert_notification(self, patient_id: UUID) -> None: + """Envia notificação de novo alerta para o profissional.""" + logger.info( + "notification.alert_notification_queued", + patient_id=str(patient_id), + ) diff --git a/backend/src/pequi/workers/adherence_worker.py b/backend/src/pequi/workers/adherence_worker.py new file mode 100644 index 0000000..a787b51 --- /dev/null +++ b/backend/src/pequi/workers/adherence_worker.py @@ -0,0 +1,94 @@ +"""Worker ARQ: cálculo de adesão periódico (cron diário).""" + +from datetime import UTC, datetime, timedelta + +from pequi.core.logging import get_logger +from pequi.database import AsyncSessionLocal +from pequi.repositories.adherence_repo import AdherenceRepository +from pequi.services.adherence_service import AdherenceService + +logger = get_logger(__name__) + + +async def adherence_job(ctx: dict) -> None: + """Calcula adesão para todos os tratamentos ativos (cron diário 00:05 UTC).""" + logger.info("adherence_job.started") + + # Use session from context if provided (for tests), otherwise create new one + session = ctx.get("db_session") + if session is None: + session = AsyncSessionLocal() + should_close = True + else: + should_close = False + + try: + adherence_repo = AdherenceRepository(session) + adherence_service = AdherenceService() + + # Get all active treatments + treatments = await adherence_repo.list_active_treatments() + logger.info("adherence_job.active_treatments", count=len(treatments)) + + for treatment in treatments: + try: + # Calculate for 7-day period + period_end = datetime.now(UTC) + period_start = period_end - timedelta(days=7) + period_start_date = period_end.date() - timedelta(days=7) + + total_doses, taken_doses = await adherence_repo.get_dose_counts_in_period( + treatment.id, period_start, period_end + ) + + adherence_pct = adherence_service.calculate_pct(total_doses, taken_doses) + + await adherence_repo.upsert_snapshot( + patient_id=treatment.patient_id, + treatment_id=treatment.id, + period_start=period_start_date, + period_end=period_end.date(), + total_doses=total_doses, + taken_doses=taken_doses, + adherence_pct=adherence_pct, + ) + + logger.info( + "adherence_snapshot.created", + treatment_id=str(treatment.id), + patient_id=str(treatment.patient_id), + adherence_pct=float(adherence_pct), + ) + + # If adherence < 70%, enqueue notification job + if adherence_pct < 70: + await ctx["redis"].enqueue_job( + "notification_job", + str(treatment.patient_id), + "low_adherence", + "", # feedback_content (empty for low_adherence) + _queue_name="pequi:default", + ) + logger.warning( + "adherence.low", + treatment_id=str(treatment.id), + patient_id=str(treatment.patient_id), + adherence_pct=float(adherence_pct), + ) + + except Exception as e: + logger.error( + "adherence_job.error", + treatment_id=str(treatment.id), + error=str(e), + exc_info=True, + ) + continue # Continue to next treatment + + await session.commit() + + finally: + if should_close: + await session.close() + + logger.info("adherence_job.completed") diff --git a/backend/src/pequi/workers/ai_feedback_worker.py b/backend/src/pequi/workers/ai_feedback_worker.py index dae1c19..329aa34 100644 --- a/backend/src/pequi/workers/ai_feedback_worker.py +++ b/backend/src/pequi/workers/ai_feedback_worker.py @@ -2,14 +2,20 @@ from uuid import UUID +from pequi.core.logging import get_logger from pequi.database import AsyncSessionLocal from pequi.repositories.checkin_repo import CheckinRepository from pequi.services.ai_feedback_service import AIFeedbackService from pequi.services.notification_service import NotificationService +logger = get_logger(__name__) + async def ai_feedback_job(ctx: dict, checkin_id: str) -> None: + """Gera feedback de IA para check-in com intensidade >= 7 e notifica o paciente.""" checkin_uuid = UUID(checkin_id) + logger.info("ai_feedback_job.started", checkin_id=str(checkin_uuid)) + ai_service = AIFeedbackService() notification_service = NotificationService() @@ -17,9 +23,25 @@ async def ai_feedback_job(ctx: dict, checkin_id: str) -> None: checkin_repo = CheckinRepository(session) checkin = await checkin_repo.get_by_id(checkin_uuid) if checkin is None: + logger.warning("ai_feedback_job.checkin_not_found", checkin_id=str(checkin_uuid)) return - feedback = await ai_service.generate_feedback(checkin) - await checkin_repo.update_ai_feedback(checkin_uuid, feedback) - await notification_service.send_feedback(checkin.patient_id, feedback) - await session.commit() + try: + feedback = await ai_service.generate_feedback(checkin) + await checkin_repo.update_ai_feedback(checkin_uuid, feedback) + await notification_service.send_feedback(checkin.patient_id, feedback) + await session.commit() + + logger.info( + "ai_feedback_job.completed", + checkin_id=str(checkin_uuid), + patient_id=str(checkin.patient_id), + ) + except Exception as e: + logger.error( + "ai_feedback_job.error", + checkin_id=str(checkin_uuid), + error=str(e), + exc_info=True, + ) + raise diff --git a/backend/src/pequi/workers/job_enqueue.py b/backend/src/pequi/workers/job_enqueue.py index b7362ab..aac7117 100644 --- a/backend/src/pequi/workers/job_enqueue.py +++ b/backend/src/pequi/workers/job_enqueue.py @@ -25,6 +25,11 @@ class JobEnqueuer: async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: raise NotImplementedError + async def enqueue_notification( + self, patient_id: UUID, notification_type: str, feedback_content: str = "" + ) -> None: + raise NotImplementedError + class ArqJobEnqueuer(JobEnqueuer): async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: @@ -36,10 +41,33 @@ async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: ) logger.info("ai_feedback.enqueued", checkin_id=str(checkin_id)) + async def enqueue_notification( + self, patient_id: UUID, notification_type: str, feedback_content: str = "" + ) -> None: + pool = await _get_pool() + await pool.enqueue_job( + "notification_job", + str(patient_id), + notification_type, + feedback_content, + _queue_name=WorkerSettings.queue_name, + ) + logger.info( + "notification.enqueued", + patient_id=str(patient_id), + notification_type=notification_type, + ) + class NoOpJobEnqueuer(JobEnqueuer): def __init__(self) -> None: - self.enqueued: list[UUID] = [] + self.enqueued_ai_feedback: list[UUID] = [] + self.enqueued_notifications: list[tuple[UUID, str, str]] = [] async def enqueue_ai_feedback(self, checkin_id: UUID) -> None: - self.enqueued.append(checkin_id) + self.enqueued_ai_feedback.append(checkin_id) + + async def enqueue_notification( + self, patient_id: UUID, notification_type: str, feedback_content: str = "" + ) -> None: + self.enqueued_notifications.append((patient_id, notification_type, feedback_content)) diff --git a/backend/src/pequi/workers/notification_worker.py b/backend/src/pequi/workers/notification_worker.py new file mode 100644 index 0000000..80765ca --- /dev/null +++ b/backend/src/pequi/workers/notification_worker.py @@ -0,0 +1,95 @@ +"""Worker ARQ: envio de notificações WhatsApp.""" + +from uuid import UUID + +from pequi.core.logging import get_logger +from pequi.database import AsyncSessionLocal +from pequi.repositories.patient_repo import PatientRepository +from pequi.services.notification_service import NotificationService + +logger = get_logger(__name__) + + +async def notification_job( + ctx: dict, + patient_id: str, + notification_type: str, + feedback_content: str = "", +) -> None: + """Envia notificação ao paciente via WhatsApp. + + Tipos suportados: + - 'dose_reminder': lembrete diário de dose + - 'ai_feedback': feedback gerado pela IA + - 'low_adherence': alerta de baixa adesão para profissional + - 'alert_generated': novo alerta para profissional + """ + VALID_NOTIFICATION_TYPES = {"dose_reminder", "ai_feedback", "low_adherence", "alert_generated"} + + if notification_type not in VALID_NOTIFICATION_TYPES: + logger.error( + "notification_job.invalid_type", + patient_id=patient_id, + notification_type=notification_type, + ) + raise ValueError(f"Invalid notification_type: {notification_type}") + + patient_uuid = UUID(patient_id) + logger.info( + "notification_job.started", + patient_id=str(patient_uuid), + notification_type=notification_type, + ) + + async with AsyncSessionLocal() as session: + patient_repo = PatientRepository(session) + notification_service = NotificationService() + + patient = await patient_repo.get_by_id(patient_uuid) + if patient is None: + logger.warning( + "notification_job.patient_not_found", + patient_id=str(patient_uuid), + ) + return + + try: + # Check if patient has notifications enabled (for patient-facing notifications) + PATIENT_FACING_TYPES = {"dose_reminder", "ai_feedback"} + if notification_type in PATIENT_FACING_TYPES and not patient.notifications_enabled: + logger.info( + "notification_job.skipped_notifications_disabled", + patient_id=str(patient_uuid), + notification_type=notification_type, + ) + return + + if notification_type == "dose_reminder": + await notification_service.send_dose_reminder(patient_uuid) + elif notification_type == "ai_feedback": + await notification_service.send_feedback(patient_uuid, feedback_content) + elif notification_type == "low_adherence": + await notification_service.send_low_adherence_alert(patient_uuid) + elif notification_type == "alert_generated": + await notification_service.send_alert_notification(patient_uuid) + + logger.info( + "notification_job.sent", + patient_id=str(patient_uuid), + notification_type=notification_type, + ) + + except Exception as e: + logger.error( + "notification_job.error", + patient_id=str(patient_uuid), + notification_type=notification_type, + error=str(e), + exc_info=True, + ) + # Don't raise - we don't want to crash the worker on notification failures + # The notification can be retried later + + await session.commit() + + logger.info("notification_job.completed") diff --git a/backend/src/pequi/workers/settings.py b/backend/src/pequi/workers/settings.py index 34cc85b..528c620 100644 --- a/backend/src/pequi/workers/settings.py +++ b/backend/src/pequi/workers/settings.py @@ -1,7 +1,11 @@ +from arq import cron from arq.connections import RedisSettings from pequi.config import get_settings +from pequi.workers.adherence_worker import adherence_job from pequi.workers.ai_feedback_worker import ai_feedback_job +from pequi.workers.notification_worker import notification_job +from pequi.workers.summary_worker import summary_job settings = get_settings() @@ -11,6 +15,16 @@ class WorkerSettings: queue_name = "pequi:default" redis_settings = RedisSettings.from_dsn(settings.REDIS_URL) - functions = [ai_feedback_job] + functions = [ + adherence_job, + notification_job, + summary_job, + ai_feedback_job, + ] + cron_jobs = [ + cron(adherence_job, hour=0, minute=5), # diário 00:05 UTC + cron(summary_job, weekday=6, hour=1), # domingo 01:00 UTC + ] max_jobs = 10 - job_timeout = 120 + job_timeout = 300 # 5 minutos + keep_result = 3600 # resultado mantido 1 hora diff --git a/backend/src/pequi/workers/summary_worker.py b/backend/src/pequi/workers/summary_worker.py new file mode 100644 index 0000000..cb719bd --- /dev/null +++ b/backend/src/pequi/workers/summary_worker.py @@ -0,0 +1,84 @@ +"""Worker ARQ: resumo semanal de sintomas (cron semanal).""" + +from datetime import UTC, datetime, timedelta + +from pequi.core.logging import get_logger +from pequi.database import AsyncSessionLocal +from pequi.repositories.weekly_summary_repo import WeeklySummaryRepository + +logger = get_logger(__name__) + + +async def summary_job(ctx: dict) -> None: + """Gera resumos semanais de sintomas para todos os pacientes ativos (cron domingo 01:00 UTC).""" + logger.info("summary_job.started") + + # Use session from context if provided (for tests), otherwise create new one + session = ctx.get("db_session") + if session is None: + session = AsyncSessionLocal() + should_close = True + else: + should_close = False + + try: + summary_repo = WeeklySummaryRepository(session) + + # Get all active patients + patient_ids = await summary_repo.list_active_patients() + logger.info("summary_job.active_patients", count=len(patient_ids)) + + # Calculate week boundaries (Sunday to Saturday) + today = datetime.now(UTC).date() + # weekday(): Monday=0, Sunday=6 + # Calculate days since most recent Saturday (0 if today is Saturday) + days_since_saturday = (today.weekday() + 2) % 7 # Saturday=0, Sunday=1, ..., Friday=6 + week_end = today - timedelta(days=days_since_saturday) + week_start = week_end - timedelta(days=6) + # Use DATE boundaries with < instead of <= to avoid edge cases + week_start_dt = datetime.combine(week_start, datetime.min.time()).replace(tzinfo=UTC) + next_day = week_end + timedelta(days=1) + week_end_dt = datetime.combine(next_day, datetime.min.time()).replace(tzinfo=UTC) + + for patient_id in patient_ids: + try: + ( + avg_intensity, + dominant_mood, + checkin_count, + alert_count, + ) = await summary_repo.get_weekly_stats(patient_id, week_start_dt, week_end_dt) + + await summary_repo.upsert_summary( + patient_id=patient_id, + week_start=week_start, + week_end=week_end, + avg_intensity=avg_intensity, + dominant_mood=dominant_mood, + checkin_count=checkin_count, + alert_count=alert_count, + ) + + logger.info( + "weekly_summary.created", + patient_id=str(patient_id), + week_start=week_start.isoformat(), + week_end=week_end.isoformat(), + checkin_count=checkin_count, + ) + + except Exception as e: + logger.error( + "summary_job.error", + patient_id=str(patient_id), + error=str(e), + exc_info=True, + ) + + await session.commit() + + finally: + if should_close: + await session.close() + + logger.info("summary_job.completed") diff --git a/backend/tests/integration/test_adherence_worker.py b/backend/tests/integration/test_adherence_worker.py new file mode 100644 index 0000000..0a53677 --- /dev/null +++ b/backend/tests/integration/test_adherence_worker.py @@ -0,0 +1,318 @@ +"""Testes de integração do adherence_worker.""" + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.dose_log import AdherenceSnapshot, DoseLog +from pequi.models.health_professional import HealthProfessional +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentStatus +from pequi.models.user import User +from pequi.repositories.adherence_repo import AdherenceRepository +from pequi.services.adherence_service import AdherenceService +from pequi.workers.adherence_worker import adherence_job + + +async def _create_patient_with_treatment( + db_session: AsyncSession, +) -> tuple[UUID, UUID, UUID]: + """Helper para criar paciente com tratamento ativo. + + Returns: + (patient_id, treatment_id, professional_id) + """ + patient_id = uuid4() + treatment_id = uuid4() + user_id = uuid4() + health_unit_id = uuid4() + professional_user_id = uuid4() + professional_id = uuid4() + + # Criar user necessário para FK + user = User( + id=user_id, + email=f"test{user_id}@example.com", + hashed_password="hashed", + full_name="Test User", + role="patient", + ) + db_session.add(user) + await db_session.flush() + + # Criar health_unit necessário para FK + health_unit = HealthUnit( + id=health_unit_id, + name="Test Health Unit", + city="Test City", + state="SP", + ) + db_session.add(health_unit) + await db_session.flush() + + # Criar patient_profile necessário para FK + patient = PatientProfile( + id=patient_id, + user_id=user_id, + health_unit_id=health_unit_id, + date_of_birth=date(1990, 1, 1), + ) + db_session.add(patient) + await db_session.flush() + + # Criar user para health professional + professional_user = User( + id=professional_user_id, + email=f"prof{professional_user_id}@example.com", + hashed_password="hashed", + full_name="Test Professional", + role="health_professional", + ) + db_session.add(professional_user) + await db_session.flush() + + # Criar health professional necessário para FK + health_professional = HealthProfessional( + id=professional_id, + user_id=professional_user_id, + health_unit_id=health_unit_id, + ) + db_session.add(health_professional) + await db_session.flush() + + # Criar treatment necessário para FK + treatment = Treatment( + id=treatment_id, + patient_id=patient_id, + prescribed_by=professional_id, + regimen="MB", + start_date=date(2026, 1, 1), + expected_end=date(2026, 12, 31), + status=TreatmentStatus.active, + ) + db_session.add(treatment) + await db_session.flush() + + return patient_id, treatment_id, professional_id + + +@pytest.mark.asyncio +async def test_adherence_service_calculates_correctly(): + """Testa o cálculo de adesão do serviço.""" + service = AdherenceService() + + # 3 de 10 doses tomadas = 30% + result = service.calculate_pct(10, 3) + assert result == Decimal("30.00") + + # 0 doses = 0% + result = service.calculate_pct(10, 0) + assert result == Decimal("0.00") + + # 10 de 10 doses = 100% + result = service.calculate_pct(10, 10) + assert result == Decimal("100.00") + + # 0 total doses = 0% (evita divisão por zero) + result = service.calculate_pct(0, 0) + assert result == Decimal("0.00") + + +@pytest.mark.asyncio +async def test_adherence_repo_upsert_is_idempotent(db_session: AsyncSession): + """Testa que upsert de snapshot é idempotente.""" + repo = AdherenceRepository(db_session) + patient_id, treatment_id, _ = await _create_patient_with_treatment(db_session) + period_start = date(2026, 1, 1) + period_end = date(2026, 1, 7) + + # Primeiro upsert + snapshot1 = await repo.upsert_snapshot( + patient_id=patient_id, + treatment_id=treatment_id, + period_start=period_start, + period_end=period_end, + total_doses=10, + taken_doses=7, + adherence_pct=Decimal("70.00"), + ) + assert snapshot1.adherence_pct == Decimal("70.00") + + # Commit para garantir que o snapshot foi persistido + await db_session.commit() + + # Recarregar o snapshot do banco + from sqlalchemy import select + + stmt = select(AdherenceSnapshot).where( + AdherenceSnapshot.treatment_id == treatment_id, + AdherenceSnapshot.period_start == period_start, + AdherenceSnapshot.period_end == period_end, + ) + result = await db_session.execute(stmt) + snapshot_reloaded = result.scalar_one() + assert snapshot_reloaded.adherence_pct == Decimal("70.00") + + # Segundo upsert (deve atualizar, não duplicar) + snapshot2 = await repo.upsert_snapshot( + patient_id=patient_id, + treatment_id=treatment_id, + period_start=period_start, + period_end=period_end, + total_doses=10, + taken_doses=8, + adherence_pct=Decimal("80.00"), + ) + assert snapshot2.id == snapshot1.id # Mesmo ID + assert snapshot2.adherence_pct == Decimal("80.00") # Valores atualizados + + # Verificar que não há duplicatas + stmt = select(AdherenceSnapshot).where( + AdherenceSnapshot.treatment_id == treatment_id, + AdherenceSnapshot.period_start == period_start, + AdherenceSnapshot.period_end == period_end, + ) + result = await db_session.execute(stmt) + snapshots = list(result.scalars().all()) + assert len(snapshots) == 1 + + +@pytest.mark.asyncio +async def test_adherence_repo_counts_doses_in_period(db_session: AsyncSession): + """Testa contagem de doses em um período.""" + repo = AdherenceRepository(db_session) + patient_id, treatment_id, _ = await _create_patient_with_treatment(db_session) + + # Criar doses no período + now = datetime.now(UTC) + week_ago = now - timedelta(days=7) + + dose1 = DoseLog( + treatment_id=treatment_id, + drug_name="Dapsone", + expected_at=week_ago + timedelta(days=1), + taken_at=week_ago + timedelta(days=1), + ) + dose2 = DoseLog( + treatment_id=treatment_id, + drug_name="Rifampicin", + expected_at=week_ago + timedelta(days=2), + taken_at=None, # Não tomada + ) + dose3 = DoseLog( + treatment_id=treatment_id, + drug_name="Clofazimine", + expected_at=week_ago + timedelta(days=3), + taken_at=week_ago + timedelta(days=3), + ) + + db_session.add_all([dose1, dose2, dose3]) + await db_session.flush() + + # Contar doses no período + total, taken = await repo.get_dose_counts_in_period(treatment_id, week_ago, now) + assert total == 3 + assert taken == 2 + + +@pytest.mark.asyncio +async def test_adherence_repo_lists_active_treatments(db_session: AsyncSession): + """Testa listagem de tratamentos ativos.""" + repo = AdherenceRepository(db_session) + patient_id, _, professional_id = await _create_patient_with_treatment(db_session) + + # Marcar tratamento existente como completed para não interferir + stmt = ( + update(Treatment) + .where(Treatment.patient_id == patient_id) + .values(status=TreatmentStatus.completed) + ) + await db_session.execute(stmt) + await db_session.flush() + + # Criar tratamento ativo + active_treatment = Treatment( + patient_id=patient_id, + prescribed_by=professional_id, + regimen="MB", + start_date=date(2026, 1, 1), + expected_end=date(2026, 12, 31), + status=TreatmentStatus.active, + ) + + # Criar tratamento completado + completed_treatment = Treatment( + patient_id=patient_id, + prescribed_by=professional_id, + regimen="PB", + start_date=date(2025, 1, 1), + expected_end=date(2025, 6, 30), + status=TreatmentStatus.completed, + ) + + db_session.add_all([active_treatment, completed_treatment]) + await db_session.flush() + + # Listar tratamentos ativos + active_treatments = await repo.list_active_treatments() + assert len(active_treatments) == 1 + assert active_treatments[0].id == active_treatment.id + assert active_treatments[0].status == TreatmentStatus.active + + +@pytest.mark.asyncio +async def test_adherence_job_processes_active_treatments(db_session: AsyncSession, mocker): + """Testa que o job de adesão processa tratamentos ativos.""" + # Mock do Redis para evitar enqueue real + mock_redis = mocker.AsyncMock() + mock_redis.enqueue_job = mocker.AsyncMock() + + ctx = {"redis": mock_redis, "db_session": db_session} + + # Criar tratamento ativo com doses + patient_id, treatment_id, _ = await _create_patient_with_treatment(db_session) + + now = datetime.now(UTC) + week_ago = now - timedelta(days=7) + + dose1 = DoseLog( + treatment_id=treatment_id, + drug_name="Dapsone", + expected_at=week_ago + timedelta(days=1), + taken_at=week_ago + timedelta(days=1), + ) + dose2 = DoseLog( + treatment_id=treatment_id, + drug_name="Rifampicin", + expected_at=week_ago + timedelta(days=2), + taken_at=week_ago + timedelta(days=2), + ) + dose3 = DoseLog( + treatment_id=treatment_id, + drug_name="Clofazimine", + expected_at=week_ago + timedelta(days=3), + taken_at=None, + ) + + db_session.add_all([dose1, dose2, dose3]) + await db_session.flush() + + # Executar o job + await adherence_job(ctx) + + # Verificar que snapshot foi criado + from sqlalchemy import select + + stmt = select(AdherenceSnapshot).where(AdherenceSnapshot.treatment_id == treatment_id) + result = await db_session.execute(stmt) + snapshot = result.scalar_one_or_none() + + assert snapshot is not None + assert snapshot.total_doses == 3 + assert snapshot.taken_doses == 2 + assert snapshot.adherence_pct == Decimal("66.67") # 2/3 = 66.67% diff --git a/backend/tests/integration/test_summary_worker.py b/backend/tests/integration/test_summary_worker.py new file mode 100644 index 0000000..b6132d8 --- /dev/null +++ b/backend/tests/integration/test_summary_worker.py @@ -0,0 +1,171 @@ +"""Testes unitários do summary_worker.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.checkin import Checkin +from pequi.models.health_professional import HealthProfessional +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentStatus +from pequi.models.user import User +from pequi.models.weekly_summary import WeeklySymptomSummary +from pequi.repositories.weekly_summary_repo import WeeklySummaryRepository +from pequi.workers.summary_worker import summary_job + + +@pytest.mark.asyncio +async def test_summary_job_processes_active_patients(db_session: AsyncSession, mocker): + """Testa que o job de resumo processa pacientes ativos.""" + # Mock do logger para evitar logs reais + mocker.patch("pequi.workers.summary_worker.logger") + + patient_id = uuid4() + user_id = uuid4() + health_unit_id = uuid4() + professional_user_id = uuid4() + professional_id = uuid4() + treatment_id = uuid4() + + # Criar user necessário para FK + user = User( + id=user_id, + email=f"test{user_id}@example.com", + hashed_password="hashed", + full_name="Test User", + role="patient", + ) + db_session.add(user) + await db_session.flush() + + # Criar health_unit necessário para FK + health_unit = HealthUnit( + id=health_unit_id, + name="Test Health Unit", + city="Test City", + state="SP", + ) + db_session.add(health_unit) + await db_session.flush() + + # Criar patient_profile necessário para FK + patient = PatientProfile( + id=patient_id, + user_id=user_id, + health_unit_id=health_unit_id, + date_of_birth=datetime(1990, 1, 1).date(), + ) + db_session.add(patient) + await db_session.flush() + + # Criar user para health professional + professional_user = User( + id=professional_user_id, + email=f"prof{professional_user_id}@example.com", + hashed_password="hashed", + full_name="Test Professional", + role="health_professional", + ) + db_session.add(professional_user) + await db_session.flush() + + # Criar health professional necessário para FK + health_professional = HealthProfessional( + id=professional_id, + user_id=professional_user_id, + health_unit_id=health_unit_id, + ) + db_session.add(health_professional) + await db_session.flush() + + # Criar treatment ativo necessário para list_active_patients + treatment = Treatment( + id=treatment_id, + patient_id=patient_id, + prescribed_by=professional_id, + regimen="MB", + start_date=datetime(2026, 1, 1).date(), + expected_end=datetime(2026, 12, 31).date(), + status=TreatmentStatus.active, + ) + db_session.add(treatment) + await db_session.flush() + + # Criar checkins na semana atual (usar datetime.now() real) + now = datetime.now(UTC) + # Calcular a semana da mesma forma que o worker faz (Sunday-Saturday) + today = now.date() + days_since_saturday = (today.weekday() + 2) % 7 # Saturday=0, Sunday=1, ..., Friday=6 + week_end = today - timedelta(days=days_since_saturday) + week_start = week_end - timedelta(days=6) + # Criar checkins dentro da semana calculada + checkin1_day = week_start + timedelta(days=1) + checkin2_day = week_start + timedelta(days=2) + checkin1_date = datetime.combine(checkin1_day, datetime.min.time()).replace(tzinfo=UTC) + checkin2_date = datetime.combine(checkin2_day, datetime.min.time()).replace(tzinfo=UTC) + checkin1 = Checkin( + id=uuid4(), + patient_id=patient_id, + symptom_intensity=5, + mood="good", + checked_in_at=checkin1_date, + ) + checkin2 = Checkin( + id=uuid4(), + patient_id=patient_id, + symptom_intensity=7, + mood="ok", + checked_in_at=checkin2_date, + ) + db_session.add_all([checkin1, checkin2]) + await db_session.flush() + await db_session.commit() + + # Executar o job + ctx = {"db_session": db_session} + await summary_job(ctx) + + # Verificar que summary foi criado + stmt = select(WeeklySymptomSummary).where(WeeklySymptomSummary.patient_id == patient_id) + result = await db_session.execute(stmt) + summary = result.scalar_one_or_none() + + assert summary is not None + assert summary.checkin_count == 2 + + +@pytest.mark.asyncio +async def test_summary_job_handles_errors_gracefully(db_session: AsyncSession, mocker): + """Testa que o job trata erros gracefully.""" + # Mock do logger para capturar erros + mock_logger = mocker.MagicMock() + mocker.patch("pequi.workers.summary_worker.logger", mock_logger) + + # Mock do repo para lançar erro + async def mock_list_active_patients(self): + return [uuid4()] + + async def mock_get_weekly_stats(self, *args, **kwargs): + raise Exception("Test error") + + mocker.patch.object( + WeeklySummaryRepository, + "list_active_patients", + mock_list_active_patients, + ) + mocker.patch.object( + WeeklySummaryRepository, + "get_weekly_stats", + mock_get_weekly_stats, + ) + + # Executar o job - não deve lançar exceção + ctx = {"db_session": db_session} + await summary_job(ctx) + + # Verificar que erro foi logado + assert mock_logger.error.called From bdab300c73e1ce2f906ab81ae18603ea66c43dca Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 16:49:07 -0300 Subject: [PATCH 30/69] ci(0.0.2-rc-1): remove some unnecessary jobs at yml (#40) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose --- backend/docker-compose.staging.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/backend/docker-compose.staging.yml b/backend/docker-compose.staging.yml index 3e02958..47362cf 100644 --- a/backend/docker-compose.staging.yml +++ b/backend/docker-compose.staging.yml @@ -6,10 +6,6 @@ services: restart: always env_file: - .env.staging - environment: - DATABASE_URL: ${DATABASE_URL} - SECRET_KEY: ${SECRET_KEY} - ENV: ${ENV} command: alembic upgrade head depends_on: db: @@ -21,18 +17,6 @@ services: restart: always env_file: - .env.staging - environment: - DATABASE_URL: ${DATABASE_URL} - REDIS_URL: ${REDIS_URL} - STORAGE_ENDPOINT: ${STORAGE_ENDPOINT} - STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} - STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} - STORAGE_BUCKET_IMAGES: ${STORAGE_BUCKET_IMAGES} - STORAGE_REGION: ${STORAGE_REGION} - SECRET_KEY: ${SECRET_KEY} - ENV: ${ENV} - SENTRY_DSN: ${SENTRY_DSN} - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} depends_on: db: condition: service_healthy @@ -49,17 +33,6 @@ services: env_file: - .env.staging command: python -m arq pequi.workers.settings.WorkerSettings - environment: - DATABASE_URL: ${DATABASE_URL} - REDIS_URL: ${REDIS_URL} - STORAGE_ENDPOINT: ${STORAGE_ENDPOINT} - STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} - STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} - STORAGE_BUCKET_IMAGES: ${STORAGE_BUCKET_IMAGES} - STORAGE_REGION: ${STORAGE_REGION} - SECRET_KEY: ${SECRET_KEY} - ENV: ${ENV} - SENTRY_DSN: ${SENTRY_DSN} depends_on: db: condition: service_healthy From 721599cfc357c1b5caef5ad149973f49562ffa1e Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 17:19:40 -0300 Subject: [PATCH 31/69] ci(0.0.2-rc-1): try to fiz error at staging (#41) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose * fix: update restart policy and remove unnecessary environment variables in staging docker-compose --- backend/docker-compose.staging.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/docker-compose.staging.yml b/backend/docker-compose.staging.yml index 47362cf..7c4d0e3 100644 --- a/backend/docker-compose.staging.yml +++ b/backend/docker-compose.staging.yml @@ -3,7 +3,7 @@ services: migrate: image: ${IMAGE_TAG} container_name: migrate_staging - restart: always + restart: "no" # ← corrigido env_file: - .env.staging command: alembic upgrade head @@ -11,6 +11,7 @@ services: db: condition: service_healthy + api: image: ${IMAGE_TAG} container_name: api_staging @@ -45,15 +46,12 @@ services: restart: always env_file: - .env.staging - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + # remover bloco environment daqui volumes: - pgdata_staging:/var/lib/postgresql/data - ./db-init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + test: ["CMD-SHELL", "pg_isready -U pequi"] interval: 5s timeout: 5s retries: 10 @@ -78,10 +76,8 @@ services: restart: always env_file: - .env.staging + # remover bloco environment daqui command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} ports: - "9000:9000" - "9002:9001" From 3717b13db9c78b2659aca21add7e54316fdc69d6 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 17:24:52 -0300 Subject: [PATCH 32/69] ci(0.0.2-rc-1): update migration command in staging and production (#42) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose * fix: update restart policy and remove unnecessary environment variables in staging docker-compose * fix(0.0.2-rc-1): update migration command in staging and production deployment scripts --- .github/workflows/deploy.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0fd1cfe..b1be31b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -70,8 +70,7 @@ jobs: IMAGE_TAG="$IMAGE_TAG" \ docker compose -f docker-compose.staging.yml up -d --no-build - docker compose -f docker-compose.staging.yml exec -T migrate \ - alembic upgrade head + docker compose -f docker-compose.staging.yml run --rm migrate docker image prune -f env: @@ -167,8 +166,7 @@ jobs: IMAGE_TAG="$IMAGE_TAG" \ docker compose -f docker-compose.prod.yml up -d --no-build - docker compose -f docker-compose.prod.yml exec -T migrate \ - alembic upgrade head + docker compose -f docker-compose.prod.yml run --rm migrate docker image prune -f env: From e0fcab2840814d92d13db1c258febfaaa98bdc90 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 17:33:59 -0300 Subject: [PATCH 33/69] ci(0.0.2-rc-1):update to staging environment (#43) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose * fix: update restart policy and remove unnecessary environment variables in staging docker-compose * fix(0.0.2-rc-1): update migration command in staging and production deployment scripts * fix(0.0.2-rc-1): update to staging environment --- .github/workflows/deploy.yml | 4 ++-- backend/nginx/nginx.staging.conf | 32 ++++---------------------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1be31b..49f5ccb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -101,7 +101,7 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd ~/pequi + cd ~/pequi/backend echo "=== Logs do container api (últimas 100 linhas) ===" docker compose -f docker-compose.staging.yml logs --tail=100 api @@ -197,6 +197,6 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd ~/pequi + cd ~/pequi/backend echo "=== Logs do container api (últimas 100 linhas) ===" docker compose -f docker-compose.prod.yml logs --tail=100 api diff --git a/backend/nginx/nginx.staging.conf b/backend/nginx/nginx.staging.conf index 927d3ab..2d75972 100644 --- a/backend/nginx/nginx.staging.conf +++ b/backend/nginx/nginx.staging.conf @@ -1,40 +1,16 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { worker_connections 1024; } +events { + worker_connections 1024; +} http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - sendfile on; - tcp_nopush on; - keepalive_timeout 65; - server { listen 8080; - server_name _; - return 301 https://$host$request_uri; - } - - server { - listen 8443 ssl; - server_name _; - - ssl_certificate /etc/nginx/certs/staging/fullchain.pem; - ssl_certificate_key /etc/nginx/certs/staging/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; location / { proxy_pass http://api_staging:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; - proxy_set_header Connection ""; } } -} +} \ No newline at end of file From e3ed30f5ba349d9b5d268fa31a2408e8e0be5499 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 17:42:07 -0300 Subject: [PATCH 34/69] ci(0.0.2-rc-1): trigger staging deploy after nginx fix (#44) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose * fix: update restart policy and remove unnecessary environment variables in staging docker-compose * fix(0.0.2-rc-1): update migration command in staging and production deployment scripts * fix(0.0.2-rc-1): update to staging environment * ci(0.0.2-rc-1): trigger staging deploy after nginx fix Co-authored-by: Rafael Luciano From e48eb9756676385d94d310ea6530599ebf2bcc22 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 17:52:41 -0300 Subject: [PATCH 35/69] ci(0.0.2-rc-1): increase delay time to response API (#45) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose * fix: update restart policy and remove unnecessary environment variables in staging docker-compose * fix(0.0.2-rc-1): update migration command in staging and production deployment scripts * fix(0.0.2-rc-1): update to staging environment * ci(0.0.2-rc-1): trigger staging deploy after nginx fix * fix(0.0.2-rc-1): increase delay time to response API --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 49f5ccb..a819499 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -85,7 +85,7 @@ jobs: echo "Aguardando API inicializar..." sleep 15 STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - --max-time 30 --retry 5 --retry-delay 10 \ + --max-time 60 --retry 5 --retry-delay 15 \ \ "http://${{ secrets.EC2_HOST }}:8080/docs") echo "HTTP status: $STATUS" if [ "$STATUS" != "200" ]; then @@ -181,7 +181,7 @@ jobs: echo "Aguardando API inicializar..." sleep 15 STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - --max-time 30 --retry 5 --retry-delay 10 \ + --max-time 60 --retry 5 --retry-delay 15 \ "http://${{ secrets.EC2_HOST }}/docs") echo "HTTP status: $STATUS" if [ "$STATUS" != "200" ]; then From 5ff493ea70e3f1bcbc3055340a5634b72d2b5eeb Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 18:05:03 -0300 Subject: [PATCH 36/69] ci(0.0.2-rc-1): solve syntax error at deploy archive (#46) * ci: trigger first staging deploy * ci(0.0.2-rc-1): fix ssh key and security group * fix: update deployment scripts to navigate to backend directory * refactor: remove unnecessary environment variables from staging docker-compose * fix: update restart policy and remove unnecessary environment variables in staging docker-compose * fix(0.0.2-rc-1): update migration command in staging and production deployment scripts * fix(0.0.2-rc-1): update to staging environment * ci(0.0.2-rc-1): trigger staging deploy after nginx fix * fix(0.0.2-rc-1): increase delay time to response API * fix(0.0.2-rc-1): solve syntax error at deploy archive --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a819499..bbb9ece 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -85,7 +85,7 @@ jobs: echo "Aguardando API inicializar..." sleep 15 STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - --max-time 60 --retry 5 --retry-delay 15 \ \ + --max-time 60 --retry 5 --retry-delay 15 \ "http://${{ secrets.EC2_HOST }}:8080/docs") echo "HTTP status: $STATUS" if [ "$STATUS" != "200" ]; then From 1da50a007f4c9bc47e22b2f29419631858ebe353 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 18:16:51 -0300 Subject: [PATCH 37/69] ci(0.0.2-rc-1): Add more steps to check health (#47) * fix(0.0.2-rc-1): Add more steps to check health --- .github/workflows/deploy.yml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bbb9ece..7d46b79 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -83,15 +83,18 @@ jobs: id: healthcheck run: | echo "Aguardando API inicializar..." - sleep 15 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - --max-time 60 --retry 5 --retry-delay 15 \ - "http://${{ secrets.EC2_HOST }}:8080/docs") - echo "HTTP status: $STATUS" - if [ "$STATUS" != "200" ]; then - echo "::error::Health check falhou — HTTP $STATUS" - exit 1 - fi + sleep 30 + for i in 1 2 3 4 5; do + STATUS=$(curl -s -w "\n%{http_code}" "http://${{ secrets.EC2_HOST }}:8080/docs" | tail -1) + echo "Tentativa $i — HTTP status: $STATUS" + if [ "$STATUS" = "200" ]; then + echo "Health check OK!" + exit 0 + fi + sleep 15 + done + echo "::error::Health check falhou após 5 tentativas" + exit 1 - name: Exibir logs do container api (apenas em falha) if: failure() && steps.healthcheck.outcome == 'failure' From a33eb19ff4297e8baba7341418cd97eb1da68f89 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Thu, 28 May 2026 18:25:58 -0300 Subject: [PATCH 38/69] ci(0.0.2-rc-1): trigger staging deploy validation (#48) * ci(0.0.2-rc-1): trigger staging deploy validation From 4f9136f2f5157c1d2707e5dda5e88d5d4e5acea2 Mon Sep 17 00:00:00 2001 From: Sarah Domingos <92494941+sarahdomingos@users.noreply.github.com> Date: Fri, 29 May 2026 10:46:07 -0300 Subject: [PATCH 39/69] [PEQ-24-25]: Criar fluxo de login e cadastro (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cadastro e login integrados * fix: organização das dependencias de auth no frontend * test: testes unitários para auth, login e register * fix: 'newMedicationName' does not exist in type 'AppointmentFollowUpDraft' build error * fix: botão de logout e cor no menu mobile selecionado * test: correções em arquivos de testes --------- Co-authored-by: lawtherea --- backend/.env.example | 2 +- backend/bruno/auth/register.bru | 4 + backend/docker-compose.yml | 2 +- backend/src/pequi/config.py | 2 +- frontend/src/app/app.config.ts | 13 +- frontend/src/app/app.html | 2 +- frontend/src/app/app.routes.ts | 6 + frontend/src/app/app.ts | 1 + .../app/components/app-header/app-header.html | 312 ++++++++++++------ .../app/components/app-header/app-header.ts | 39 ++- .../checkin-step-feeling-component.spec.ts | 4 +- frontend/src/app/components/menu/menu.html | 213 ++++++------ frontend/src/app/components/menu/menu.spec.ts | 4 +- frontend/src/app/components/menu/menu.ts | 39 +-- .../health-appointment.service.spec.ts | 16 +- frontend/src/app/features/auth/auth.css | 0 frontend/src/app/features/auth/auth.html | 1 + frontend/src/app/features/auth/auth.spec.ts | 22 ++ frontend/src/app/features/auth/auth.ts | 9 + .../features/auth/guards/auth-guard.spec.ts | 17 + .../app/features/auth/guards/auth-guard.ts | 16 + .../auth/interceptors/auth-interceptor.ts | 54 +++ .../auth/services/auth-service.spec.ts | 291 ++++++++++++++++ .../features/auth/services/auth-service.ts | 131 ++++++++ .../src/app/features/checkin/checkin.spec.ts | 25 -- frontend/src/app/features/checkin/checkin.ts | 2 - frontend/src/app/features/login/login.css | 139 ++++++++ frontend/src/app/features/login/login.html | 32 ++ frontend/src/app/features/login/login.spec.ts | 239 ++++++++++++++ frontend/src/app/features/login/login.ts | 56 ++++ .../src/app/features/register/register.css | 139 ++++++++ .../src/app/features/register/register.html | 47 +++ .../app/features/register/register.spec.ts | 226 +++++++++++++ .../src/app/features/register/register.ts | 67 ++++ .../app-shell-component.html | 10 +- .../app-shell-component.spec.ts | 6 +- 36 files changed, 1906 insertions(+), 282 deletions(-) create mode 100644 frontend/src/app/features/auth/auth.css create mode 100644 frontend/src/app/features/auth/auth.html create mode 100644 frontend/src/app/features/auth/auth.spec.ts create mode 100644 frontend/src/app/features/auth/auth.ts create mode 100644 frontend/src/app/features/auth/guards/auth-guard.spec.ts create mode 100644 frontend/src/app/features/auth/guards/auth-guard.ts create mode 100644 frontend/src/app/features/auth/interceptors/auth-interceptor.ts create mode 100644 frontend/src/app/features/auth/services/auth-service.spec.ts create mode 100644 frontend/src/app/features/auth/services/auth-service.ts create mode 100644 frontend/src/app/features/login/login.css create mode 100644 frontend/src/app/features/login/login.html create mode 100644 frontend/src/app/features/login/login.spec.ts create mode 100644 frontend/src/app/features/login/login.ts create mode 100644 frontend/src/app/features/register/register.css create mode 100644 frontend/src/app/features/register/register.html create mode 100644 frontend/src/app/features/register/register.spec.ts create mode 100644 frontend/src/app/features/register/register.ts diff --git a/backend/.env.example b/backend/.env.example index 6bff3ce..59e26d2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -43,7 +43,7 @@ ENV=development # development | staging | production SECRET_KEY=change-me-in-production # chave aleatória de 64+ caracteres ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 -ALLOWED_ORIGINS=["http://localhost:3000"] +ALLOWED_ORIGINS=["http://localhost:3000", "http://localhost:4200"] # ── Banco de dados ───────────────────────────────────────────────────────────── DATABASE_URL=postgresql+asyncpg://pequi:pequi@localhost:5432/pequi diff --git a/backend/bruno/auth/register.bru b/backend/bruno/auth/register.bru index 85980d1..7cc7bf4 100644 --- a/backend/bruno/auth/register.bru +++ b/backend/bruno/auth/register.bru @@ -22,6 +22,10 @@ body:json { } } +vars:pre-request { + baseUrl: http://localhost:8000 +} + assert { res.status: eq 201 res.body.email: eq "paciente@test.com" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 18dfc8f..a45abe6 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -25,7 +25,7 @@ services: SECRET_KEY: dev-secret-key-change-in-production ENV: development SENTRY_DSN: "" - ALLOWED_ORIGINS: '["http://localhost:3000","http://localhost:8000"]' + ALLOWED_ORIGINS: '["http://localhost:3000","http://localhost:8000", "http://localhost:4200"]' depends_on: db: condition: service_healthy diff --git a/backend/src/pequi/config.py b/backend/src/pequi/config.py index 9cadf1e..d2045b3 100644 --- a/backend/src/pequi/config.py +++ b/backend/src/pequi/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): SECRET_KEY: str ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"] + ALLOWED_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:4200"] # Banco de dados DATABASE_URL: str diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..0c4e40c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,11 +1,16 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; +import { authInterceptor } from './features/auth/interceptors/auth-interceptor'; +import 'zone.js'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) - ] -}; + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + ], +}; \ No newline at end of file diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 67e7bd4..90c6b64 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1 +1 @@ - + \ No newline at end of file diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c7c6764..c88a6d2 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -10,11 +10,17 @@ import { CommunityPostPage } from './features/comunity/community-post-page/commu import { Profile } from './features/profile/profile'; import { Notification } from './components/notification/notification'; import { RegisterAppointmentComponent } from './features/appointments/register-appointment/register-appointment'; +import { Login } from './features/login/login'; +import { Register } from './features/register/register'; +import { authGuard } from './features/auth/guards/auth-guard'; export const routes: Routes = [ + { path: 'login', component: Login }, + { path: 'register', component: Register }, { path: '', component: AppShellComponent, + canActivate: [authGuard], children: [ { path: '', pathMatch: 'full', redirectTo: 'home' }, { path: 'home', component: HomeComponent, title: 'Início' }, diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index ade0fcb..927adb1 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', + standalone: true, imports: [RouterOutlet], templateUrl: './app.html', styleUrl: './app.css' diff --git a/frontend/src/app/components/app-header/app-header.html b/frontend/src/app/components/app-header/app-header.html index e255bb1..d272598 100644 --- a/frontend/src/app/components/app-header/app-header.html +++ b/frontend/src/app/components/app-header/app-header.html @@ -25,7 +25,8 @@ pageTitle() }}
-
+ +
- } - + + + @if (isProfileMenuOpen()) { +
+ + + +
+ }
} @else { - } @@ -151,6 +238,7 @@ pageTitle() }} +
+ +
} @else {
- + } + + + + + - - @if (avatarUrl()) { - - } @else { - - } - - + @if (avatarUrl()) { + + } @else { + + } + + +
} - + \ No newline at end of file diff --git a/frontend/src/app/components/app-header/app-header.ts b/frontend/src/app/components/app-header/app-header.ts index f85b93e..510229c 100644 --- a/frontend/src/app/components/app-header/app-header.ts +++ b/frontend/src/app/components/app-header/app-header.ts @@ -1,11 +1,14 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, HostListener, computed, inject, input, signal } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { LucideArrowLeft, LucideBell, LucideDynamicIcon, + LucideLogOut, + LucideUser, } from '@lucide/angular'; import { NotificationHubService } from '../../core/notifications/notification-hub.service'; +import { AuthService } from '../../features/auth/services/auth-service'; export type AppHeaderLayout = 'default' | 'withBack'; @@ -20,6 +23,7 @@ const ICON_BTN = }) export class AppHeader { private readonly router = inject(Router); + private readonly authService = inject(AuthService); protected readonly notifications = inject(NotificationHubService); readonly layout = input('default'); @@ -28,11 +32,12 @@ export class AppHeader { readonly avatarUrl = input(null); readonly profileLink = input('/profile'); readonly backLink = input('/home'); - /** When true, notification bell keeps a flat background on hover/focus/active (e.g. on /notifications). */ readonly quietNotificationButton = input(false); readonly LucideBell = LucideBell; readonly LucideArrowLeft = LucideArrowLeft; + readonly LucideLogOut = LucideLogOut; + readonly LucideUser = LucideUser; readonly unread = this.notifications.unreadCount; readonly showUnreadBadge = computed(() => this.unread() > 0); @@ -44,7 +49,35 @@ export class AppHeader { : `${ICON_BTN} relative` ); + readonly isProfileMenuOpen = signal(false); + onNotificationsClick(): void { void this.router.navigateByUrl('/notifications'); } -} + + toggleProfileMenu(event: Event): void { + event.stopPropagation(); + this.isProfileMenuOpen.update(value => !value); + } + + closeProfileMenu(): void { + this.isProfileMenuOpen.set(false); + } + + onProfileClick(event: Event): void { + event.stopPropagation(); + this.closeProfileMenu(); + void this.router.navigateByUrl(this.profileLink()); + } + + logout(event?: Event): void { + event?.stopPropagation(); + this.closeProfileMenu(); + this.authService.logout(); + } + + @HostListener('document:click') + onDocumentClick(): void { + this.closeProfileMenu(); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.spec.ts b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.spec.ts index 1726c81..305bca4 100644 --- a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.spec.ts +++ b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.spec.ts @@ -92,8 +92,8 @@ describe(CheckinStepFeelingComponent.name, () => { fixture.detectChanges(); - expect(form.get('mood')?.value).toBe('muito-bem'); - expect(component.isSelected('muito-bem')).toBeTruthy(); + expect(form.get('mood')?.value).toBe('mal'); + expect(component.isSelected('mal')).toBeTruthy(); }); it('should display validation message when mood is invalid and touched', () => { diff --git a/frontend/src/app/components/menu/menu.html b/frontend/src/app/components/menu/menu.html index 938da07..9b879fd 100644 --- a/frontend/src/app/components/menu/menu.html +++ b/frontend/src/app/components/menu/menu.html @@ -1,118 +1,121 @@
-
- - - + {{ item.label }} + + + + + + - - + + + {{ item.label }} + + + + + \ No newline at end of file diff --git a/frontend/src/app/components/menu/menu.spec.ts b/frontend/src/app/components/menu/menu.spec.ts index 1838a05..ae83f00 100644 --- a/frontend/src/app/components/menu/menu.spec.ts +++ b/frontend/src/app/components/menu/menu.spec.ts @@ -72,7 +72,7 @@ it('should render all menu items in desktop and mobile nav', () => { }); it('should emit collapsedChange when toggled', () => { - const emitSpy = vi.spyOn(component.collapsedChange, 'emit'); + const emitSpy = vi.spyOn(component.menuCollapsedChange, 'emit'); component.toggleSidebar(); expect(emitSpy).toHaveBeenCalledWith(true); @@ -108,8 +108,6 @@ it('should render all menu items in desktop and mobile nav', () => { const communityLink = fixture.debugElement .queryAll(By.css('nav a')) .find((el) => el.nativeElement.getAttribute('href')?.includes('/comunity')); - - expect(component.linkActiveOptions(communityItem)).toEqual({ exact: false }); expect(communityLink?.nativeElement.className).toContain('bg-[#E0E7FF]'); }); }); diff --git a/frontend/src/app/components/menu/menu.ts b/frontend/src/app/components/menu/menu.ts index cea57b8..df0db1c 100644 --- a/frontend/src/app/components/menu/menu.ts +++ b/frontend/src/app/components/menu/menu.ts @@ -1,22 +1,19 @@ -// menu.component.ts import { Component, EventEmitter, Output, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { - LucideDynamicIcon, - LucideHouse, - LucideMap, - LucideMapPinned, - LucideGraduationCap, - LucideUsers, - LucideIcon, -} from '@lucide/angular'; + LucideAngularModule, + House, + Map, + MapPinned, + GraduationCap, + Users, +} from 'lucide-angular'; type NavItem = { label: string; route: string; - icon: LucideIcon; - /** When false, child routes (e.g. /comunity/feed) also mark the item active. */ + icon: any; exactLink?: boolean; }; @@ -27,32 +24,28 @@ type NavItem = { CommonModule, RouterLink, RouterLinkActive, - LucideDynamicIcon, + LucideAngularModule, ], templateUrl: './menu.html', styleUrl: './menu.css', }) export class Menu { - @Output() collapsedChange = new EventEmitter(); + @Output() menuCollapsedChange = new EventEmitter(); isCollapsed = signal(false); navItems: NavItem[] = [ - { label: 'Início', route: '/home', icon: LucideHouse }, - { label: 'Jornada', route: '/journey', icon: LucideMap }, - { label: 'Check In', route: '/checkin', icon: LucideMapPinned }, - { label: 'Educação', route: '/education', icon: LucideGraduationCap }, - { label: 'Comunidade', route: '/comunity', icon: LucideUsers, exactLink: false }, + { label: 'Início', route: '/home', icon: House }, + { label: 'Jornada', route: '/journey', icon: Map }, + { label: 'Check In', route: '/checkin', icon: MapPinned }, + { label: 'Educação', route: '/education', icon: GraduationCap }, + { label: 'Comunidade', route: '/comunity', icon: Users, exactLink: false }, ]; - linkActiveOptions(item: NavItem): { exact: boolean } { - return { exact: item.exactLink !== false }; - } - toggleSidebar(): void { const next = !this.isCollapsed(); this.isCollapsed.set(next); - this.collapsedChange.emit(next); + this.menuCollapsedChange .emit(next); } trackByRoute = (_: number, item: NavItem) => item.route; diff --git a/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts b/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts index 158363b..37d5d91 100644 --- a/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts +++ b/frontend/src/app/features/appointments/services/health-appointment.service.spec.ts @@ -73,13 +73,19 @@ describe('HealthAppointmentService', () => { performed: true, followUp: { ...EMPTY_FOLLOW_UP_DRAFT, - hadMedicationChange: true, - newMedicationName: 'Clofazimina', - newDoseDescription: '1 cápsula ao dia', + updateInstitutedMedsFromConsultation: true, medicationChangeDescription: 'Ajuste do esquema', + institutedPrednisoneMgKg: '1', + institutedAineMgDay: '2', + institutedThalidomideMgDay: '3', + institutedPentoxifyllineMgDay: '4', }, }); - expect(record.followUp?.medicationChange?.newMedicationName).toBe('Clofazimina'); - expect(record.followUp?.medicationChange?.newDoseDescription).toBe('1 cápsula ao dia'); + expect(record.followUp?.hadMedicationChange).toBe(true); + expect(record.followUp?.medicationChange?.newMedicationName).toBe( + 'Medicamentos instituídos atualizados', + ); + expect(record.followUp?.medicationChange?.newDoseDescription).toContain('Prednisona 1 mg/kg'); + expect(record.followUp?.medicationChange?.description).toBe('Ajuste do esquema'); }); }); diff --git a/frontend/src/app/features/auth/auth.css b/frontend/src/app/features/auth/auth.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/features/auth/auth.html b/frontend/src/app/features/auth/auth.html new file mode 100644 index 0000000..f66eb69 --- /dev/null +++ b/frontend/src/app/features/auth/auth.html @@ -0,0 +1 @@ +

auth works!

diff --git a/frontend/src/app/features/auth/auth.spec.ts b/frontend/src/app/features/auth/auth.spec.ts new file mode 100644 index 0000000..67cd060 --- /dev/null +++ b/frontend/src/app/features/auth/auth.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Auth } from './auth'; + +describe('Auth', () => { + let component: Auth; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Auth], + }).compileComponents(); + + fixture = TestBed.createComponent(Auth); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/auth/auth.ts b/frontend/src/app/features/auth/auth.ts new file mode 100644 index 0000000..85fb8e4 --- /dev/null +++ b/frontend/src/app/features/auth/auth.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-auth', + imports: [], + templateUrl: './auth.html', + styleUrl: './auth.css', +}) +export class Auth {} diff --git a/frontend/src/app/features/auth/guards/auth-guard.spec.ts b/frontend/src/app/features/auth/guards/auth-guard.spec.ts new file mode 100644 index 0000000..0f21aa3 --- /dev/null +++ b/frontend/src/app/features/auth/guards/auth-guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { CanActivateFn } from '@angular/router'; + +import { authGuard } from './auth-guard'; + +describe('authGuard', () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => authGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/auth/guards/auth-guard.ts b/frontend/src/app/features/auth/guards/auth-guard.ts new file mode 100644 index 0000000..d100722 --- /dev/null +++ b/frontend/src/app/features/auth/guards/auth-guard.ts @@ -0,0 +1,16 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth-service'; + +export const authGuard: CanActivateFn = (_route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated()) { + return true; + } + + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); +}; \ No newline at end of file diff --git a/frontend/src/app/features/auth/interceptors/auth-interceptor.ts b/frontend/src/app/features/auth/interceptors/auth-interceptor.ts new file mode 100644 index 0000000..1d1d1d2 --- /dev/null +++ b/frontend/src/app/features/auth/interceptors/auth-interceptor.ts @@ -0,0 +1,54 @@ +import { inject } from '@angular/core'; +import { + HttpErrorResponse, + HttpHandlerFn, + HttpInterceptorFn, + HttpRequest, +} from '@angular/common/http'; +import { AuthService } from '../services/auth-service'; +import { catchError, switchMap, throwError } from 'rxjs'; + +const isAuthRoute = (url: string): boolean => { + return url.includes('/v1/auth/login') || url.includes('/v1/auth/register') || url.includes('/v1/auth/refresh'); +}; + +export const authInterceptor: HttpInterceptorFn = (req: HttpRequest, next: HttpHandlerFn) => { + const authService = inject(AuthService); + + const accessToken = authService.getAccessToken(); + + + const authReq = + accessToken && !isAuthRoute(req.url) + ? req.clone({ + setHeaders: { + Authorization: `Bearer ${accessToken}`, + }, + }) + : req; + + return next(authReq).pipe( + catchError((error: HttpErrorResponse) => { + const refreshToken = authService.getRefreshToken(); + + if (error.status !== 401 || !refreshToken || isAuthRoute(req.url)) { + return throwError(() => error); + } + return authService.refreshToken().pipe( + switchMap(response => { + const retryReq = req.clone({ + setHeaders: { + Authorization: `Bearer ${response.access_token}`, + }, + }); + + return next(retryReq); + }), + catchError(refreshError => { + authService.logout(); + return throwError(() => refreshError); + }) + ); + }) + ); +}; \ No newline at end of file diff --git a/frontend/src/app/features/auth/services/auth-service.spec.ts b/frontend/src/app/features/auth/services/auth-service.spec.ts new file mode 100644 index 0000000..02032c4 --- /dev/null +++ b/frontend/src/app/features/auth/services/auth-service.spec.ts @@ -0,0 +1,291 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AuthService } from './auth-service'; + +export interface RegisterRequest { + email: string; + password: string; + full_name: string; +} + +export interface AuthUser { + id: string; + email: string; + full_name: string; + role: string; + is_active: boolean; + is_verified: boolean; + created_at: string; + updated_at: string; +} + +export interface RegisterResponse extends AuthUser {} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RefreshTokenRequest { + refresh_token: string; +} + +export interface AuthTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + user: AuthUser; +} + +export interface AuthSession { + accessToken: string; + refreshToken: string; + expiresIn: number; + tokenType: string; + user: AuthUser; +} + +describe('AuthService', () => { + let service: AuthService; + let httpMock: HttpTestingController; + + const navigateMock = vi.fn(); + + const mockUser: AuthUser = { + id: 'user-1', + email: 'teste@teste.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: false, + created_at: '2026-05-28T14:06:17.974436Z', + updated_at: '2026-05-28T14:06:17.974436Z', + }; + + const mockAuthResponse: AuthTokenResponse = { + access_token: 'access-token-123', + refresh_token: 'refresh-token-456', + expires_in: 1800, + token_type: 'bearer', + user: mockUser, + }; + + beforeEach(() => { + localStorage.clear(); + navigateMock.mockReset(); + navigateMock.mockResolvedValue(true); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + AuthService, + { + provide: Router, + useValue: { + navigate: navigateMock, + }, + }, + ], + }); + + service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + it('should call register with the correct payload', () => { + const payload: RegisterRequest = { + email: 'teste@teste.com', + password: '123456', + full_name: 'Sarah', + }; + + let responseBody: AuthUser | undefined; + + service.register(payload).subscribe(response => { + responseBody = response; + }); + + const req = httpMock.expectOne('http://localhost:8000/v1/auth/register'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(payload); + + req.flush(mockUser); + + expect(responseBody).toEqual(mockUser); + }); + + it('should call login and save session in localStorage', () => { + const payload: LoginRequest = { + email: 'teste@teste.com', + password: '123456', + }; + + let responseBody: AuthTokenResponse | undefined; + + service.login(payload).subscribe(response => { + responseBody = response; + }); + + const req = httpMock.expectOne('http://localhost:8000/v1/auth/login'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(payload); + + req.flush(mockAuthResponse); + + expect(responseBody).toEqual(mockAuthResponse); + + const session = JSON.parse(localStorage.getItem('auth_session') ?? '{}'); + expect(session).toEqual({ + accessToken: mockAuthResponse.access_token, + refreshToken: mockAuthResponse.refresh_token, + expiresIn: mockAuthResponse.expires_in, + tokenType: mockAuthResponse.token_type, + user: mockAuthResponse.user, + }); + }); + + it('should return true from isAuthenticated when access token exists', () => { + localStorage.setItem( + 'auth_session', + JSON.stringify({ + accessToken: 'token-123', + refreshToken: 'refresh-123', + expiresIn: 1800, + tokenType: 'bearer', + user: mockUser, + }) + ); + + expect(service.isAuthenticated()).toBe(true); + }); + + it('should return false from isAuthenticated when there is no session', () => { + expect(service.isAuthenticated()).toBe(false); + }); + + it('should return access token from session', () => { + localStorage.setItem( + 'auth_session', + JSON.stringify({ + accessToken: 'token-123', + refreshToken: 'refresh-123', + expiresIn: 1800, + tokenType: 'bearer', + user: mockUser, + }) + ); + + expect(service.getAccessToken()).toBe('token-123'); + }); + + it('should return refresh token from session', () => { + localStorage.setItem( + 'auth_session', + JSON.stringify({ + accessToken: 'token-123', + refreshToken: 'refresh-123', + expiresIn: 1800, + tokenType: 'bearer', + user: mockUser, + }) + ); + + expect(service.getRefreshToken()).toBe('refresh-123'); + }); + + it('should return current user from session', () => { + localStorage.setItem( + 'auth_session', + JSON.stringify({ + accessToken: 'token-123', + refreshToken: 'refresh-123', + expiresIn: 1800, + tokenType: 'bearer', + user: mockUser, + }) + ); + + expect(service.getCurrentUser()).toEqual(mockUser); + }); + + it('should return null from getters when localStorage session is invalid JSON', () => { + localStorage.setItem('auth_session', '{invalid-json'); + + expect(service.getAccessToken()).toBeNull(); + expect(service.getRefreshToken()).toBeNull(); + expect(service.getCurrentUser()).toBeNull(); + expect(service.isAuthenticated()).toBe(false); + }); + + it('should call refresh token endpoint and update session', () => { + localStorage.setItem( + 'auth_session', + JSON.stringify({ + accessToken: 'old-access-token', + refreshToken: 'refresh-token-456', + expiresIn: 1800, + tokenType: 'bearer', + user: mockUser, + }) + ); + + let responseBody: AuthTokenResponse | undefined; + + service.refreshToken().subscribe(response => { + responseBody = response; + }); + + const req = httpMock.expectOne('http://localhost:8000/v1/auth/refresh'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + refresh_token: 'refresh-token-456', + }); + + req.flush(mockAuthResponse); + + expect(responseBody).toEqual(mockAuthResponse); + + const session = JSON.parse(localStorage.getItem('auth_session') ?? '{}'); + expect(session.accessToken).toBe(mockAuthResponse.access_token); + expect(session.refreshToken).toBe(mockAuthResponse.refresh_token); + expect(session.user).toEqual(mockUser); + }); + + it('should logout and throw error when refresh token is missing', () => { + expect(() => service.refreshToken()).toThrowError('Refresh token não encontrado.'); + + expect(localStorage.getItem('auth_session')).toBeNull(); + expect(navigateMock).toHaveBeenCalledWith(['/login']); + }); + + it('should remove session and navigate to login on logout', () => { + localStorage.setItem( + 'auth_session', + JSON.stringify({ + accessToken: 'token-123', + refreshToken: 'refresh-123', + expiresIn: 1800, + tokenType: 'bearer', + user: mockUser, + }) + ); + + service.logout(); + + expect(localStorage.getItem('auth_session')).toBeNull(); + expect(navigateMock).toHaveBeenCalledWith(['/login']); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/auth/services/auth-service.ts b/frontend/src/app/features/auth/services/auth-service.ts new file mode 100644 index 0000000..7ddbbdf --- /dev/null +++ b/frontend/src/app/features/auth/services/auth-service.ts @@ -0,0 +1,131 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { Observable, tap } from 'rxjs'; + +export interface RegisterRequest { + email: string; + password: string; + full_name: string; +} + +export interface AuthUser { + id: string; + email: string; + full_name: string; + role: string; + is_active: boolean; + is_verified: boolean; + created_at: string; + updated_at: string; +} + +export interface RegisterResponse extends AuthUser {} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RefreshTokenRequest { + refresh_token: string; +} + +export interface AuthTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + user: AuthUser; +} + +export interface AuthSession { + accessToken: string; + refreshToken: string; + expiresIn: number; + tokenType: string; + user: AuthUser; +} + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + private readonly http = inject(HttpClient); + private readonly router = inject(Router); + + private readonly baseUrl = 'http://localhost:8000/v1/auth'; + private readonly sessionKey = 'auth_session'; + + register(payload: RegisterRequest): Observable { + return this.http.post(`${this.baseUrl}/register`, payload); + } + + login(payload: LoginRequest): Observable { + return this.http + .post(`${this.baseUrl}/login`, payload) + .pipe(tap(response => this.setSession(response))); + } + +refreshToken(): Observable { + const refreshToken = this.getRefreshToken(); + + if (!refreshToken) { + this.logout(); + throw new Error('Refresh token não encontrado.'); + } + + const payload: RefreshTokenRequest = { + refresh_token: refreshToken, + }; + + return this.http + .post(`${this.baseUrl}/refresh`, payload) + .pipe(tap(response => this.setSession(response))); +} + + logout(): void { + localStorage.removeItem(this.sessionKey); + void this.router.navigate(['/login']); + } + + isAuthenticated(): boolean { + return !!this.getAccessToken(); + } + + getAccessToken(): string | null { + return this.getSession()?.accessToken ?? null; + } + + getRefreshToken(): string | null { + return this.getSession()?.refreshToken ?? null; + } + + getCurrentUser(): AuthUser | null { + return this.getSession()?.user ?? null; + } + + private setSession(response: AuthTokenResponse): void { + const session: AuthSession = { + accessToken: response.access_token, + refreshToken: response.refresh_token, + expiresIn: response.expires_in, + tokenType: response.token_type, + user: response.user, + }; + + localStorage.setItem(this.sessionKey, JSON.stringify(session)); + } + + private getSession(): AuthSession | null { + const raw = localStorage.getItem(this.sessionKey); + + if (!raw) return null; + + try { + return JSON.parse(raw) as AuthSession; + } catch { + return null; + } + } +} diff --git a/frontend/src/app/features/checkin/checkin.spec.ts b/frontend/src/app/features/checkin/checkin.spec.ts index 085bdeb..b1678d6 100644 --- a/frontend/src/app/features/checkin/checkin.spec.ts +++ b/frontend/src/app/features/checkin/checkin.spec.ts @@ -409,35 +409,26 @@ describe(CheckinComponent.name, () => { }); it('should submit and navigate to home when form is valid in regular flow', () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - component.feelingForm.get('mood')?.setValue('happy'); component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); component.intensityForm.get('scale')?.setValue(1); component.detailsForm.get('notes')?.setValue('feeling well'); component.submit(); - - expect(consoleSpy).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['home']); }); it('should submit and navigate to home when "nenhum sintoma" skips intensity', () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); component.feelingForm.get('mood')?.setValue('happy'); component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); component.detailsForm.get('notes')?.setValue('sem sintomas hoje'); component.submit(); - - expect(consoleSpy).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['home']); }); it('should submit payload with only selectedSymptoms inside symptoms object', () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - component.feelingForm.get('mood')?.setValue('sad'); component.symptomsForm.get('selectedSymptoms')?.setValue(['nausea']); component.symptomsForm.get('customSymptom')?.setValue('other symptom'); @@ -445,30 +436,14 @@ describe(CheckinComponent.name, () => { component.detailsForm.get('notes')?.setValue('extra notes'); component.submit(); - - expect(consoleSpy).toHaveBeenCalledWith('Payload final do check-in:', { - feeling: { mood: 'sad' }, - symptoms: { selectedSymptoms: ['nausea'] }, - intensity: { scale: 5 }, - details: { notes: 'extra notes' }, - }); }); it('should submit payload with null intensity when "nenhum sintoma" is selected', () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - component.feelingForm.get('mood')?.setValue('good'); component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); component.detailsForm.get('notes')?.setValue('sem observações'); component.submit(); - - expect(consoleSpy).toHaveBeenCalledWith('Payload final do check-in:', { - feeling: { mood: 'good' }, - symptoms: { selectedSymptoms: ['nenhum sintoma'] }, - intensity: { scale: null }, - details: { notes: 'sem observações' }, - }); }); it('should follow the regular flow without skipping when there are symptoms', () => { diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index 3902e7e..6cdf0f9 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -187,8 +187,6 @@ export class CheckinComponent { selectedSymptoms: rawValue.symptoms.selectedSymptoms, }, }; - - console.log('Payload final do check-in:', payload); this.router.navigate(['home']); } diff --git a/frontend/src/app/features/login/login.css b/frontend/src/app/features/login/login.css new file mode 100644 index 0000000..651d2fe --- /dev/null +++ b/frontend/src/app/features/login/login.css @@ -0,0 +1,139 @@ +:root { + --white: #ffffff; + --default-purple: #4338ca; + --hover-purple: #e0e7ff; + --light-purple: #c0b9ff; +} + +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + linear-gradient(180deg, var(--hover-purple) 0%, var(--white) 100%); +} + +.auth-card { + width: 100%; + max-width: 420px; + padding: 32px; + border-radius: 24px; + background: var(--white); + box-shadow: 0 18px 45px rgba(67, 56, 202, 0.12); + border: 1px solid rgba(192, 185, 255, 0.45); +} + +.auth-header { + margin-bottom: 24px; +} + +.auth-badge { + display: inline-flex; + margin-bottom: 12px; + padding: 6px 12px; + border-radius: 999px; + background: var(--hover-purple); + color: var(--default-purple); + font-size: 13px; + font-weight: 700; +} + +.auth-header h1 { + margin: 0 0 8px; + color: var(--default-purple); + font-size: 28px; + line-height: 1.15; +} + +.auth-header p { + margin: 0; + color: #5b5b74; + font-size: 14px; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-field span { + color: #2f2f46; + font-size: 14px; + font-weight: 600; +} + +.auth-field input { + height: 48px; + border-radius: 14px; + border: 1px solid var(--light-purple); + padding: 0 14px; + font-size: 15px; + color: #25253a; + background: var(--white); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.auth-field input:focus { + border-color: var(--default-purple); + box-shadow: 0 0 0 4px rgba(67, 56, 202, 0.12); +} + +.auth-button { + height: 48px; + border: none; + border-radius: 14px; + background: var(--default-purple); + color: var(--white); + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} + +.auth-button:hover { + background: #372fb0; +} + +.auth-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.auth-error { + margin: 0; + color: #c53030; + font-size: 14px; +} + +.auth-success { + margin: 0; + color: #2f855a; + font-size: 14px; +} + +.auth-footer { + margin-top: 20px; + text-align: center; + font-size: 14px; + color: #5b5b74; +} + +.auth-footer a { + color: var(--default-purple); + font-weight: 700; + text-decoration: none; +} + +.auth-footer a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/frontend/src/app/features/login/login.html b/frontend/src/app/features/login/login.html new file mode 100644 index 0000000..aeedab6 --- /dev/null +++ b/frontend/src/app/features/login/login.html @@ -0,0 +1,32 @@ +
+
+
+ Entrar +

Acesse sua conta

+

Faça login para continuar.

+
+ +
+ + + + +

{{ errorMessage }}

+ + +
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/features/login/login.spec.ts b/frontend/src/app/features/login/login.spec.ts new file mode 100644 index 0000000..e4c9b52 --- /dev/null +++ b/frontend/src/app/features/login/login.spec.ts @@ -0,0 +1,239 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi, describe, beforeEach, it, expect } from 'vitest'; + +import { Login } from './login'; +import { AuthService } from '../auth/services/auth-service'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + + const authServiceMock = { + login: vi.fn(), + }; + + const routerMock = { + navigateByUrl: vi.fn(), + }; + + const activatedRouteMock = { + snapshot: { + queryParamMap: convertToParamMap({}), + }, + }; + + beforeEach(async () => { + authServiceMock.login.mockReset(); + routerMock.navigateByUrl.mockReset(); + routerMock.navigateByUrl.mockResolvedValue(true); + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({}); + + await TestBed.configureTestingModule({ + imports: [Login], + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not submit when form is invalid', () => { + component.form.setValue({ + email: '', + password: '', + }); + + component.submit(); + + expect(authServiceMock.login).not.toHaveBeenCalled(); + expect(component.form.touched).toBe(true); + expect(component.isSubmitting).toBe(false); + }); + + it('should validate invalid email format', () => { + component.form.setValue({ + email: 'email-invalido', + password: '123456', + }); + + expect(component.form.invalid).toBe(true); + expect(component.form.get('email')?.invalid).toBe(true); + }); + + it('should call authService.login with the correct payload', () => { + authServiceMock.login.mockReturnValue( + of({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 1800, + token_type: 'bearer', + user: { + id: '1', + email: 'sarah@test.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: true, + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }, + }) + ); + + component.form.setValue({ + email: 'sarah@test.com', + password: '123456', + }); + + component.submit(); + + expect(authServiceMock.login).toHaveBeenCalledWith({ + email: 'sarah@test.com', + password: '123456', + }); + }); + + it('should navigate to returnUrl on successful login', () => { + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({ + returnUrl: '/checkin', + }); + + authServiceMock.login.mockReturnValue( + of({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 1800, + token_type: 'bearer', + user: { + id: '1', + email: 'sarah@test.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: true, + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }, + }) + ); + + component.form.setValue({ + email: 'sarah@test.com', + password: '123456', + }); + + component.submit(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/checkin'); + expect(component.errorMessage).toBe(''); + expect(component.isSubmitting).toBe(false); + }); + + it('should navigate to /home when returnUrl is not provided', () => { + authServiceMock.login.mockReturnValue( + of({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 1800, + token_type: 'bearer', + user: { + id: '1', + email: 'sarah@test.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: true, + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }, + }) + ); + + component.form.setValue({ + email: 'sarah@test.com', + password: '123456', + }); + + component.submit(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/home'); + }); + + it('should set isSubmitting to true while submitting', () => { + authServiceMock.login.mockReturnValue( + of({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 1800, + token_type: 'bearer', + user: { + id: '1', + email: 'sarah@test.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: true, + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }, + }) + ); + + component.form.setValue({ + email: 'sarah@test.com', + password: '123456', + }); + + component.submit(); + + expect(component.isSubmitting).toBe(false); + }); + + it('should show API error message on login error', () => { + authServiceMock.login.mockReturnValue( + throwError(() => ({ + error: { + message: 'Credenciais inválidas.', + }, + })) + ); + + component.form.setValue({ + email: 'sarah@test.com', + password: 'senha-errada', + }); + + component.submit(); + + expect(component.errorMessage).toBe('Credenciais inválidas.'); + expect(component.isSubmitting).toBe(false); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('should show default error message when API does not return message', () => { + authServiceMock.login.mockReturnValue( + throwError(() => ({ error: {} })) + ); + + component.form.setValue({ + email: 'sarah@test.com', + password: 'senha-errada', + }); + + component.submit(); + + expect(component.errorMessage).toBe('E-mail ou senha inválidos.'); + expect(component.isSubmitting).toBe(false); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts new file mode 100644 index 0000000..7b83146 --- /dev/null +++ b/frontend/src/app/features/login/login.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { AuthService } from '../auth/services/auth-service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + templateUrl: './login.html', + styleUrl: './login.css', +}) + +export class Login { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + errorMessage = ''; + isSubmitting = false; + + form = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required]], + }); + +submit(): void { + this.errorMessage = ''; + this.form.markAllAsTouched(); + if (this.form.invalid) { + return; + } + + this.isSubmitting = true; + this.authService + .login({ + email: this.form.value.email ?? '', + password: this.form.value.password ?? '', + }) + .subscribe({ + next: (response) => { + const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/home'; + void this.router.navigateByUrl(returnUrl); + }, + error: (error) => { + this.errorMessage = error?.error?.message ?? 'E-mail ou senha inválidos.'; + this.isSubmitting = false; + }, + complete: () => { + this.isSubmitting = false; + }, + }); +} +} \ No newline at end of file diff --git a/frontend/src/app/features/register/register.css b/frontend/src/app/features/register/register.css new file mode 100644 index 0000000..651d2fe --- /dev/null +++ b/frontend/src/app/features/register/register.css @@ -0,0 +1,139 @@ +:root { + --white: #ffffff; + --default-purple: #4338ca; + --hover-purple: #e0e7ff; + --light-purple: #c0b9ff; +} + +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + linear-gradient(180deg, var(--hover-purple) 0%, var(--white) 100%); +} + +.auth-card { + width: 100%; + max-width: 420px; + padding: 32px; + border-radius: 24px; + background: var(--white); + box-shadow: 0 18px 45px rgba(67, 56, 202, 0.12); + border: 1px solid rgba(192, 185, 255, 0.45); +} + +.auth-header { + margin-bottom: 24px; +} + +.auth-badge { + display: inline-flex; + margin-bottom: 12px; + padding: 6px 12px; + border-radius: 999px; + background: var(--hover-purple); + color: var(--default-purple); + font-size: 13px; + font-weight: 700; +} + +.auth-header h1 { + margin: 0 0 8px; + color: var(--default-purple); + font-size: 28px; + line-height: 1.15; +} + +.auth-header p { + margin: 0; + color: #5b5b74; + font-size: 14px; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-field span { + color: #2f2f46; + font-size: 14px; + font-weight: 600; +} + +.auth-field input { + height: 48px; + border-radius: 14px; + border: 1px solid var(--light-purple); + padding: 0 14px; + font-size: 15px; + color: #25253a; + background: var(--white); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.auth-field input:focus { + border-color: var(--default-purple); + box-shadow: 0 0 0 4px rgba(67, 56, 202, 0.12); +} + +.auth-button { + height: 48px; + border: none; + border-radius: 14px; + background: var(--default-purple); + color: var(--white); + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} + +.auth-button:hover { + background: #372fb0; +} + +.auth-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.auth-error { + margin: 0; + color: #c53030; + font-size: 14px; +} + +.auth-success { + margin: 0; + color: #2f855a; + font-size: 14px; +} + +.auth-footer { + margin-top: 20px; + text-align: center; + font-size: 14px; + color: #5b5b74; +} + +.auth-footer a { + color: var(--default-purple); + font-weight: 700; + text-decoration: none; +} + +.auth-footer a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/frontend/src/app/features/register/register.html b/frontend/src/app/features/register/register.html new file mode 100644 index 0000000..4f4476f --- /dev/null +++ b/frontend/src/app/features/register/register.html @@ -0,0 +1,47 @@ +
+
+
+ Cadastro +

Crie sua conta

+

Preencha seus dados para começar.

+
+ +
+ + + + + + + + +

{{ errorMessage }}

+

{{ successMessage }}

+ + +
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/features/register/register.spec.ts b/frontend/src/app/features/register/register.spec.ts new file mode 100644 index 0000000..dac773a --- /dev/null +++ b/frontend/src/app/features/register/register.spec.ts @@ -0,0 +1,226 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; +import { Register } from './register'; +import { AuthService } from '../auth/services/auth-service'; + +describe('Register', () => { + let component: Register; + let fixture: ComponentFixture; + let router: Router; + let navigateSpy: ReturnType; + + const authServiceMock = { + register: vi.fn(), + }; + + beforeEach(async () => { + authServiceMock.register.mockReset(); + + await TestBed.configureTestingModule({ + imports: [Register], + providers: [ + provideRouter([]), + { provide: AuthService, useValue: authServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(Register); + component = fixture.componentInstance; + router = TestBed.inject(Router); + navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + fixture.detectChanges(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not submit when form is invalid', () => { + component.form.setValue({ + full_name: '', + email: '', + password: '', + confirmPassword: '', + }); + + component.submit(); + + expect(authServiceMock.register).not.toHaveBeenCalled(); + expect(component.form.touched).toBe(true); + expect(component.isSubmitting).toBe(false); + }); + + it('should validate minimum password length', () => { + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '1234567', + confirmPassword: '1234567', + }); + + expect(component.form.invalid).toBe(true); + expect(component.form.get('password')?.invalid).toBe(true); + }); + + it('should show error when passwords do not match', () => { + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '87654321', + }); + + component.submit(); + + expect(component.errorMessage).toBe('As senhas não coincidem.'); + expect(component.successMessage).toBe(''); + expect(component.isSubmitting).toBe(false); + expect(authServiceMock.register).not.toHaveBeenCalled(); + }); + + it('should call authService.register with the correct payload', () => { + authServiceMock.register.mockReturnValue( + of({ + id: '1', + email: 'sarah@test.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: false, + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }) + ); + + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(authServiceMock.register).toHaveBeenCalledWith({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + }); + }); + + it('should navigate to /login and set success message on successful register', () => { + authServiceMock.register.mockReturnValue( + of({ + id: '1', + email: 'sarah@test.com', + full_name: 'Sarah', + role: 'patient', + is_active: true, + is_verified: false, + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }) + ); + + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(component.successMessage).toBe('Cadastro realizado com sucesso.'); + expect(component.errorMessage).toBe(''); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + expect(component.isSubmitting).toBe(false); + }); + + it('should show error detail from API when register fails', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + authServiceMock.register.mockReturnValue( + throwError(() => ({ + error: { + detail: 'E-mail já cadastrado.', + }, + })) + ); + + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(component.errorMessage).toBe('E-mail já cadastrado.'); + expect(component.successMessage).toBe(''); + expect(component.isSubmitting).toBe(false); + expect(navigateSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should show error message from API when detail is not available', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + authServiceMock.register.mockReturnValue( + throwError(() => ({ + error: { + message: 'Falha no cadastro.', + }, + })) + ); + + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(component.errorMessage).toBe('Falha no cadastro.'); + expect(component.isSubmitting).toBe(false); + expect(navigateSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should show default error message when API returns no detail or message', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + authServiceMock.register.mockReturnValue( + throwError(() => ({ + error: {}, + })) + ); + + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(component.errorMessage).toBe('Não foi possível cadastrar.'); + expect(component.isSubmitting).toBe(false); + expect(navigateSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/register/register.ts b/frontend/src/app/features/register/register.ts new file mode 100644 index 0000000..5e69dea --- /dev/null +++ b/frontend/src/app/features/register/register.ts @@ -0,0 +1,67 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../auth/services/auth-service'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + templateUrl: './register.html', + styleUrl: './register.css', +}) +export class Register { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + + errorMessage = ''; + successMessage = ''; + isSubmitting = false; + + form = this.fb.group({ + full_name: ['', [Validators.required, Validators.minLength(2)]], + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + confirmPassword: ['', [Validators.required]], + }); + +submit(): void { + this.errorMessage = ''; + this.successMessage = ''; + this.form.markAllAsTouched(); + + if (this.form.invalid) { + return; + } + + if ((this.form.value.password ?? '') !== (this.form.value.confirmPassword ?? '')) { + this.errorMessage = 'As senhas não coincidem.'; + return; + } + this.isSubmitting = true; + + this.authService.register({ + full_name: this.form.value.full_name ?? '', + email: this.form.value.email ?? '', + password: this.form.value.password ?? '', + }).subscribe({ + next: () => { + this.successMessage = 'Cadastro realizado com sucesso.'; + void this.router.navigate(['/login']); + }, + error: (error) => { + console.error('erro no cadastro:', error); + this.errorMessage = + error?.error?.detail || + error?.error?.message || + 'Não foi possível cadastrar.'; + this.isSubmitting = false; + }, + complete: () => { + this.isSubmitting = false; + }, + }); +} +} \ No newline at end of file diff --git a/frontend/src/app/layout/app-shell-component/app-shell-component.html b/frontend/src/app/layout/app-shell-component/app-shell-component.html index 835335d..0ab2dd3 100644 --- a/frontend/src/app/layout/app-shell-component/app-shell-component.html +++ b/frontend/src/app/layout/app-shell-component/app-shell-component.html @@ -1,15 +1,12 @@ -
- - +
-
-
(); + @Output() menuCollapsedChange = new EventEmitter(); } describe(AppShellComponent.name, () => { @@ -56,12 +56,12 @@ describe(AppShellComponent.name, () => { expect(content.className).not.toContain('lg:ml-20'); }); - it('should update isMenuCollapsed when menu emits collapsedChange', () => { + it('should update isMenuCollapsed when menu emits menuCollapsedChange', () => { const menu = fixture.debugElement.query( By.directive(MockMenuComponent) ).componentInstance as MockMenuComponent; - menu.collapsedChange.emit(true); + menu.menuCollapsedChange.emit(true); expect(component.isMenuCollapsed).toBeTruthy(); }); From 358e63210dde0dc866175d5d759e9fad37c7f321 Mon Sep 17 00:00:00 2001 From: Lucas Heron <111458155+LukeHer0@users.noreply.github.com> Date: Fri, 29 May 2026 11:08:55 -0300 Subject: [PATCH 40/69] =?UTF-8?q?[PEQ=2043-44]=20Implementa=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20mapa=20corporal/registro=20de=20fotos=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add body map checkin * create a new component * resolve conflicts * fix: adjust body map router * feat: identify body part + add a better svg image to body silhouette * feat(body-map): implement M5 backend API and apply PR review fixes Add body map endpoints, immutable history, check-in snapshots, storage abstraction, migration seed, Bruno collection, and tests. Address review: preserve test DB password in derived URL, validate upload extensions, block cross-tenant access when health_unit_id is missing, inject StorageService via Depends, and return 422 when professionals omit patient_id. Co-authored-by: Cursor * feat: add picture feature * feat: add picture feature * fix: switch back and front of body above the area * fix: add route in backend src file * refactor: remove unused picture --------- Co-authored-by: Matheus Ryan Co-authored-by: Cursor --- frontend/package-lock.json | 6 +- .../public/assets/body-silhouette-back.svg | 91 +++++++ .../public/assets/body-silhouette-front.svg | 96 +++++++ frontend/src/app/app.routes.ts | 2 + .../src/app/features/checkin/checkin.html | 79 +++--- frontend/src/app/features/checkin/checkin.ts | 78 +----- frontend/src/app/features/home/home.ts | 6 +- .../photo-register/photo-register.css | 0 .../photo-register/photo-register.html | 235 ++++++++++++++++++ .../photo-register/photo-register.spec.ts | 22 ++ .../features/photo-register/photo-register.ts | 200 +++++++++++++++ 11 files changed, 706 insertions(+), 109 deletions(-) create mode 100644 frontend/public/assets/body-silhouette-back.svg create mode 100644 frontend/public/assets/body-silhouette-front.svg create mode 100644 frontend/src/app/features/photo-register/photo-register.css create mode 100644 frontend/src/app/features/photo-register/photo-register.html create mode 100644 frontend/src/app/features/photo-register/photo-register.spec.ts create mode 100644 frontend/src/app/features/photo-register/photo-register.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9142b5e..13227e5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7854,9 +7854,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/frontend/public/assets/body-silhouette-back.svg b/frontend/public/assets/body-silhouette-back.svg new file mode 100644 index 0000000..e4b4725 --- /dev/null +++ b/frontend/public/assets/body-silhouette-back.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/body-silhouette-front.svg b/frontend/public/assets/body-silhouette-front.svg new file mode 100644 index 0000000..3548f40 --- /dev/null +++ b/frontend/public/assets/body-silhouette-front.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c88a6d2..5d0adf3 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -9,6 +9,7 @@ import { CommunityFeed } from './features/comunity/community-feed/community-feed import { CommunityPostPage } from './features/comunity/community-post-page/community-post-page'; import { Profile } from './features/profile/profile'; import { Notification } from './components/notification/notification'; +import { PhotoRegister } from './features/photo-register/photo-register'; import { RegisterAppointmentComponent } from './features/appointments/register-appointment/register-appointment'; import { Login } from './features/login/login'; import { Register } from './features/register/register'; @@ -37,6 +38,7 @@ export const routes: Routes = [ { path: 'comunity/feed/:postId', component: CommunityPostPage, title: 'Post' }, { path: 'profile', component: Profile, title: 'Perfil' }, { path: 'notifications', component: Notification, title: 'Notificações' }, + { path: 'photo-register', component: PhotoRegister, title: 'Registro de Fotos' }, ], }, ]; diff --git a/frontend/src/app/features/checkin/checkin.html b/frontend/src/app/features/checkin/checkin.html index cd918b2..0c9a2a0 100644 --- a/frontend/src/app/features/checkin/checkin.html +++ b/frontend/src/app/features/checkin/checkin.html @@ -1,66 +1,71 @@
- -
-
-

Check-in

-

- Etapa {{ currentStepNumber() }} de 4 -

-
- - - Progresso Diário - +
+
+

Check-in

+

Etapa {{ currentStepNumber() }} de 4

-
-
-
-
+ + Progresso Diário + +
+ +
+
+
+
-
- + + - + > --> -
+
+ +
-
\ No newline at end of file + diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index 6cdf0f9..13aef8e 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -7,12 +7,7 @@ import { signal, WritableSignal, } from '@angular/core'; -import { - FormBuilder, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { CheckinStepFeelingComponent } from '../../components/checkin-step-feeling-component/checkin-step-feeling-component'; @@ -80,8 +75,8 @@ export class CheckinComponent { }); constructor() { - this.setupCurrentStepValidationWatcher(); - this.setupIntensityConditionalValidation(); + // this.setupCurrentStepValidationWatcher(); + // this.setupIntensityConditionalValidation(); } get currentStepNumber(): WritableSignal { @@ -133,10 +128,10 @@ export class CheckinComponent { return; case 2: - if (this.hasNoSymptomsSelected()) { - this.currentStep.set(4); - return; - } + // if (this.hasNoSymptomsSelected()) { + // this.currentStep.set(4); + // return; + // } this.currentStep.set(3); return; @@ -153,10 +148,10 @@ export class CheckinComponent { prevStep(): void { switch (this.currentStep()) { case 4: - if (this.hasNoSymptomsSelected()) { - this.currentStep.set(2); - return; - } + // if (this.hasNoSymptomsSelected()) { + // this.currentStep.set(2); + // return; + // } this.currentStep.set(3); return; @@ -208,53 +203,4 @@ export class CheckinComponent { return this.feelingForm; } } - - private hasNoSymptomsSelected(): boolean { - const selectedSymptoms = - this.symptomsForm.get('selectedSymptoms')?.value ?? []; - - return selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); - } - - private setupCurrentStepValidationWatcher(): void { - effect(() => { - const step = this.currentStep(); - const currentGroup = this.getStepForm(step); - - this.stepStatusSubscription?.unsubscribe(); - this.isCurrentStepInvalid.set(currentGroup.invalid); - - this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { - this.isCurrentStepInvalid.set(currentGroup.invalid); - }); - }); - } - - private setupIntensityConditionalValidation(): void { - const selectedSymptomsControl = this.symptomsForm.get('selectedSymptoms'); - const intensityScaleControl = this.intensityForm.get('scale'); - - this.applyIntensityValidation(); - - this.symptomsSelectionSubscription = selectedSymptomsControl?.valueChanges.subscribe(() => { - this.applyIntensityValidation(); - }); - } - - private applyIntensityValidation(): void { - const intensityScaleControl = this.intensityForm.get('scale'); - - if (!intensityScaleControl) { - return; - } - - if (this.hasNoSymptomsSelected()) { - intensityScaleControl.clearValidators(); - intensityScaleControl.setValue(null, { emitEvent: false }); - } else { - intensityScaleControl.setValidators([Validators.required]); - } - - intensityScaleControl.updateValueAndValidity({ emitEvent: true }); - } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index cd56f56..73b73b9 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -1,7 +1,7 @@ import { Component, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope } from 'lucide-angular'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; interface QuickAction { title: string; @@ -65,7 +65,7 @@ export class HomeComponent implements OnInit { description: 'Acompanhe mudanças na pele', icon: this.ImagePlus, colorClass: 'green-icon', - path: '', + path: '/photo-register', }, ]; @@ -106,7 +106,7 @@ export class HomeComponent implements OnInit { dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: dateObj.getDate(), - dots: Array(Math.floor(Math.random() * 3)).fill(0), //simulação, mudar para dados reais depois + dots: Array(Math.floor(Math.random() * 3)).fill(0), }); } } diff --git a/frontend/src/app/features/photo-register/photo-register.css b/frontend/src/app/features/photo-register/photo-register.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/features/photo-register/photo-register.html b/frontend/src/app/features/photo-register/photo-register.html new file mode 100644 index 0000000..a27fa19 --- /dev/null +++ b/frontend/src/app/features/photo-register/photo-register.html @@ -0,0 +1,235 @@ + + +
+
+
+

Mapa Corporal

+

+ Toque qualquer área na silhueta para criar um novo comentário ou revisar anotações existentes. +

+
+ +
+ + +
+ +
+ + Silhueta do corpo humano + + @for (marker of visibleMarkers; track marker.id) { +
+ @if (marker.status === 'active') { + ! + } + @if (marker.status === 'cured') { + + } +
+ @if (selectedMarkerId() === marker.id) { +
+
+ Local +

{{ marker.bodyPart }}

+
+ + @if (marker.imageUrl) { +
+ Registro visual + +
+ } @else { + + } + @if (marker.status !== 'cured') { + + } @else { + + } + + +
+ } + } +
+
+ +
+
+

Visão Geral

+
+ +
+
+
+ Locais ativos + +
+ + {{ activeCount | number: '2.0' }} + +
+ + @if (isActivesExpanded()) { +
+ @for (marker of activeMarkersList; track marker.id) { +
+
+ {{ marker.bodyPart }} + {{ marker.view === 'front' ? 'Frente' : 'Costas' }} +
+ @if (marker.imageUrl) { + + } @else { +
+ +
+ } +
+ } + @if (activeMarkersList.length === 0) { + Nenhum registro ativo encontrado. + } +
+ } +
+ +
+
+
+ Locais curados + +
+ + {{ curedCount | number: '2.0' }} + +
+ + @if (isCuredExpanded()) { +
+ @for (marker of curedMarkersList; track marker.id) { +
+
+ {{ marker.bodyPart }} + {{ marker.view === 'front' ? 'Frente' : 'Costas' }} +
+ @if (marker.imageUrl) { + + } @else { +
+ +
+ } +
+ } + @if (curedMarkersList.length === 0) { + Nenhum registro curado encontrado. + } +
+ } +
+
+
+ + +
+

Como usar o Mapa

+ +
+
+
+
+ Novos Registros +

Clique em qualquer área da silhueta para marcar uma nova alteração na pele.

+
+
+ +
+
+
+ Acompanhamento +

Adicione fotos e acompanhe a evolução dos locais selecionados com o tempo.

+
+
+
+
+ +
+

+ "Notou algo novo hoje? Mudanças leves valem a pena serem documentadas para o seu registro de + bem-estar a longo prazo." +

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/features/photo-register/photo-register.spec.ts b/frontend/src/app/features/photo-register/photo-register.spec.ts new file mode 100644 index 0000000..bbff0a6 --- /dev/null +++ b/frontend/src/app/features/photo-register/photo-register.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PhotoRegister } from './photo-register'; + +describe('PhotoRegister', () => { + let component: PhotoRegister; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PhotoRegister], + }).compileComponents(); + + fixture = TestBed.createComponent(PhotoRegister); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/photo-register/photo-register.ts b/frontend/src/app/features/photo-register/photo-register.ts new file mode 100644 index 0000000..ec103d7 --- /dev/null +++ b/frontend/src/app/features/photo-register/photo-register.ts @@ -0,0 +1,200 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, signal, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { LucideAngularModule, User, Plus, History, CircleCheck, Trash2, Camera, ChevronDown, ChevronUp } from 'lucide-angular'; + +export interface BodyMarker { + id: string; + x: number; + y: number; + view: 'front' | 'back'; + status: 'active' | 'review' | 'cured'; + bodyPart: string; + imageUrl?: string; +} + +@Component({ + selector: 'app-photo-register', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], + templateUrl: './photo-register.html', +}) +export class PhotoRegister implements OnInit { + private readonly fb = inject(FormBuilder); + + form!: FormGroup; + currentView = signal<'front' | 'back'>('front'); + + public selectedMarkerId = signal(null); + + private uploadingMarkerId: string | null = null; + + markers = signal([]); + + isActivesExpanded = signal(false); + isCuredExpanded = signal(false); + + readonly UserIcon = User; + readonly PlusIcon = Plus; + readonly HistoryIcon = History; + readonly CircleCheckIcon = CircleCheck; + readonly TrashIcon = Trash2; + readonly CameraIcon = Camera; + readonly ChevronDownIcon = ChevronDown; + readonly ChevronUpIcon = ChevronUp; + + ngOnInit() { + this.form = this.fb.group({ + markers: [this.markers()] + }); + } + + toggleAtivos() { + this.isActivesExpanded.set(!this.isActivesExpanded()); + } + + toggleCurados() { + this.isCuredExpanded.set(!this.isCuredExpanded()); + } + + setView(view: 'front' | 'back'): void { + this.currentView.set(view); + this.selectedMarkerId.set(null); + } + + private identifyBodyPart(x: number, y: number, view: 'front' | 'back'): string | null { + if (x < 15 || x > 85) return null; + + if (view === 'front') { + if (y >= 0 && y < 15) return 'Face'; + if (y >= 15 && y < 20) return 'Pescoço'; + if (y >= 20 && y < 35 && (x < 35 || x > 65)) return 'Ombros'; + if (y >= 35 && y < 65 && (x < 35 || x > 65)) return 'Braços'; + if (y >= 65 && y < 80 && (x < 35 || x > 65)) return 'Mãos'; + if (y >= 20 && y < 45) return 'Abdômen'; + if (y >= 45 && y < 55) return 'Quadril'; + if (y >= 55 && y < 75) return 'Pernas'; + if (y >= 75 && y < 85) return 'Joelhos'; + if (y >= 85 && y <= 100) return 'Pés'; + } else { + if (y >= 0 && y < 15) return 'Couro cabeludo'; + if (y >= 15 && y < 20) return 'Nuca'; + if (y >= 20 && y < 45 && (x > 35 && x < 65)) return 'Costas'; + if (y >= 20 && y < 65 && (x < 35 || x > 65)) return 'Braços'; + if (y >= 45 && y < 60) return 'Glúteos'; + if (y >= 60 && y < 75) return 'Posterior das coxas'; + if (y >= 75 && y <= 100) return 'Panturrilhas'; + } + + return 'Local Indefinido'; + } + + addMarker(event: MouseEvent): void { + if (this.selectedMarkerId() !== null) { + this.selectedMarkerId.set(null); + return; + } + + const target = event.target as HTMLElement; + const rect = target.getBoundingClientRect(); + + const x = ((event.clientX - rect.left) / rect.width) * 100; + const y = ((event.clientY - rect.top) / rect.height) * 100; + + const bodyPart = this.identifyBodyPart(x, y, this.currentView()); + + if (!bodyPart) return; + + const newMarker: BodyMarker = { + id: Date.now().toString(), + x, + y, + view: this.currentView(), + status: 'active', + bodyPart + }; + + this.markers.update((current) => [...current, newMarker]); + this.updateForm(); + } + + toggleMenu(event: MouseEvent, id: string) { + event.stopPropagation(); + this.selectedMarkerId.set(this.selectedMarkerId() === id ? null : id); + } + + markAsActive(id: string) { + this.markers.update(current => + current.map(m => m.id === id ? { ...m, status: 'active' } : m) + ); + this.selectedMarkerId.set(null); + this.updateForm(); + } + + markAsCured(id: string) { + this.markers.update(current => + current.map(m => m.id === id ? { ...m, status: 'cured' } : m) + ); + this.selectedMarkerId.set(null); + this.updateForm(); + } + + removeMarker(id: string) { + this.markers.update(current => current.filter(m => m.id !== id)); + this.selectedMarkerId.set(null); + this.updateForm(); + } + + triggerImageUpload(id: string) { + this.uploadingMarkerId = id; + const fileInput = document.getElementById('marker-photo-upload') as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + } + + handleImageUpload(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + const reader = new FileReader(); + + reader.onload = (e) => { + const base64Image = e.target?.result as string; + if (this.uploadingMarkerId) { + this.markers.update(current => + current.map(m => m.id === this.uploadingMarkerId ? { ...m, imageUrl: base64Image } : m) + ); + this.updateForm(); + this.uploadingMarkerId = null; + } + }; + + reader.readAsDataURL(file); + } + } + + private updateForm() { + this.form.get('markers')?.setValue(this.markers()); + } + + get visibleMarkers() { + return this.markers().filter((m) => m.view === this.currentView()); + } + + get activeCount() { + return this.markers().filter((m) => m.status === 'active' || m.status === 'review').length; + } + + get curedCount() { + return this.markers().filter((m) => m.status === 'cured').length; + } + + get activeMarkersList() { + return this.markers().filter(m => m.status === 'active' || m.status === 'review'); + } + + get curedMarkersList() { + return this.markers().filter(m => m.status === 'cured'); + } +} From e3b8a7b070c7b98ef024014aeee22651e2a796af Mon Sep 17 00:00:00 2001 From: Leila Biggi <87096464+lawtherea@users.noreply.github.com> Date: Fri, 29 May 2026 11:11:34 -0300 Subject: [PATCH 41/69] [PEQ-139]: toast component created (#49) * [PEQ-139]: toast component created * fix: moved component to app.ts --- frontend/src/app/app.html | 3 +- frontend/src/app/app.ts | 5 +- .../app/components/toast/toast-container.html | 16 ++++ .../app/components/toast/toast-container.ts | 13 +++ frontend/src/app/components/toast/toast.html | 38 ++++++++ .../src/app/components/toast/toast.service.ts | 96 +++++++++++++++++++ frontend/src/app/components/toast/toast.ts | 93 ++++++++++++++++++ .../src/app/components/toast/toast.types.ts | 9 ++ frontend/src/app/features/home/home.ts | 2 +- 9 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/components/toast/toast-container.html create mode 100644 frontend/src/app/components/toast/toast-container.ts create mode 100644 frontend/src/app/components/toast/toast.html create mode 100644 frontend/src/app/components/toast/toast.service.ts create mode 100644 frontend/src/app/components/toast/toast.ts create mode 100644 frontend/src/app/components/toast/toast.types.ts diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 90c6b64..08c7733 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1 +1,2 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 927adb1..127989f 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,12 +1,13 @@ import { Component, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { ToastContainer } from './components/toast/toast-container'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [RouterOutlet, ToastContainer], templateUrl: './app.html', - styleUrl: './app.css' + styleUrl: './app.css', }) export class App { protected readonly title = signal('frontend'); diff --git a/frontend/src/app/components/toast/toast-container.html b/frontend/src/app/components/toast/toast-container.html new file mode 100644 index 0000000..4cc1e13 --- /dev/null +++ b/frontend/src/app/components/toast/toast-container.html @@ -0,0 +1,16 @@ +
+ @for (item of toastService.items(); track item.id) { + + } +
diff --git a/frontend/src/app/components/toast/toast-container.ts b/frontend/src/app/components/toast/toast-container.ts new file mode 100644 index 0000000..7ebbda4 --- /dev/null +++ b/frontend/src/app/components/toast/toast-container.ts @@ -0,0 +1,13 @@ +import { Component, inject } from '@angular/core'; +import { Toast } from './toast'; +import { ToastService } from './toast.service'; + +@Component({ + selector: 'app-toast-container', + standalone: true, + imports: [Toast], + templateUrl: './toast-container.html', +}) +export class ToastContainer { + protected readonly toastService = inject(ToastService); +} diff --git a/frontend/src/app/components/toast/toast.html b/frontend/src/app/components/toast/toast.html new file mode 100644 index 0000000..e629157 --- /dev/null +++ b/frontend/src/app/components/toast/toast.html @@ -0,0 +1,38 @@ +
+
diff --git a/frontend/src/app/components/toast/toast.service.ts b/frontend/src/app/components/toast/toast.service.ts new file mode 100644 index 0000000..7658bd9 --- /dev/null +++ b/frontend/src/app/components/toast/toast.service.ts @@ -0,0 +1,96 @@ +import { Injectable, signal } from '@angular/core'; +import type { ToastItem, ToastVariant } from './toast.types'; + +const DEFAULT_DURATION_MS = 4000; + +@Injectable({ providedIn: 'root' }) +export class ToastService { + readonly items = signal([]); + + private timers = new Map>(); + + success(title: string, durationMs?: number): string; + success(title: string, description: string, durationMs?: number): string; + success( + title: string, + descriptionOrDuration?: string | number, + durationMs = DEFAULT_DURATION_MS + ): string { + return this.showVariant('success', title, descriptionOrDuration, durationMs); + } + + error(title: string, durationMs?: number): string; + error(title: string, description: string, durationMs?: number): string; + error( + title: string, + descriptionOrDuration?: string | number, + durationMs = DEFAULT_DURATION_MS + ): string { + return this.showVariant('error', title, descriptionOrDuration, durationMs); + } + + warning(title: string, durationMs?: number): string; + warning(title: string, description: string, durationMs?: number): string; + warning( + title: string, + descriptionOrDuration?: string | number, + durationMs = DEFAULT_DURATION_MS + ): string { + return this.showVariant('warning', title, descriptionOrDuration, durationMs); + } + + show( + variant: ToastVariant, + title: string, + description?: string, + durationMs = DEFAULT_DURATION_MS + ): string { + const id = crypto.randomUUID(); + const item: ToastItem = { + id, + variant, + title, + ...(description ? { description } : {}), + durationMs, + }; + + this.items.update((list) => [...list, item]); + this.scheduleAutoDismiss(id, durationMs); + + return id; + } + + dismiss(id: string): void { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + this.items.update((list) => list.filter((t) => t.id !== id)); + } + + dismissAll(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + this.items.set([]); + } + + private showVariant( + variant: ToastVariant, + title: string, + descriptionOrDuration?: string | number, + durationMs = DEFAULT_DURATION_MS + ): string { + if (typeof descriptionOrDuration === 'number') { + return this.show(variant, title, undefined, descriptionOrDuration); + } + return this.show(variant, title, descriptionOrDuration, durationMs); + } + + private scheduleAutoDismiss(id: string, durationMs: number): void { + const timer = setTimeout(() => this.dismiss(id), durationMs); + this.timers.set(id, timer); + } +} diff --git a/frontend/src/app/components/toast/toast.ts b/frontend/src/app/components/toast/toast.ts new file mode 100644 index 0000000..5672e15 --- /dev/null +++ b/frontend/src/app/components/toast/toast.ts @@ -0,0 +1,93 @@ +import { Component, computed, input, output } from '@angular/core'; +import { + LucideAngularModule, + LucideCircleCheck, + LucideCircleX, + LucideTriangleAlert, + LucideX, +} from 'lucide-angular'; +import type { ToastVariant } from './toast.types'; + +type ToastStyle = { + container: string; + icon: string; +}; + +const VARIANT_STYLES: Record = { + success: { + container: 'bg-green-50 text-green-800', + icon: 'text-green-800 focus-visible:outline-green-800', + }, + error: { + container: 'bg-red-50 text-red-800', + icon: 'text-red-800 focus-visible:outline-red-800', + }, + warning: { + container: 'bg-amber-50 text-amber-700', + icon: 'text-amber-700 focus-visible:outline-amber-700', + }, +}; + +@Component({ + selector: 'app-toast', + standalone: true, + imports: [LucideAngularModule], + templateUrl: './toast.html', +}) +export class Toast { + readonly variant = input.required(); + readonly title = input.required(); + readonly description = input(null); + readonly dismissible = input(true); + + readonly hasDescription = computed(() => !!this.description()?.trim()); + + readonly dismissed = output(); + + readonly LucideCircleCheck = LucideCircleCheck; + readonly LucideCircleX = LucideCircleX; + readonly LucideTriangleAlert = LucideTriangleAlert; + readonly LucideX = LucideX; + + readonly role = computed(() => (this.variant() === 'error' ? 'alert' : 'status')); + + readonly containerClass = computed(() => { + const style = VARIANT_STYLES[this.variant()]; + return [ + 'flex items-start gap-3 rounded-xl px-4 py-3 shadow-md', + style.container, + ].join(' '); + }); + + readonly iconClass = computed(() => { + const style = VARIANT_STYLES[this.variant()]; + return ['mt-0.5 shrink-0', style.icon].join(' '); + }); + + readonly descriptionClass = computed(() => { + const base = 'm-0 mt-0.5 text-sm font-normal leading-snug opacity-90'; + switch (this.variant()) { + case 'success': + return `${base} text-green-700`; + case 'error': + return `${base} text-red-700`; + case 'warning': + return `${base} text-amber-600`; + } + }); + + readonly icon = computed(() => { + switch (this.variant()) { + case 'success': + return LucideCircleCheck; + case 'error': + return LucideCircleX; + case 'warning': + return LucideTriangleAlert; + } + }); + + onDismiss(): void { + this.dismissed.emit(); + } +} diff --git a/frontend/src/app/components/toast/toast.types.ts b/frontend/src/app/components/toast/toast.types.ts new file mode 100644 index 0000000..4ec7fc7 --- /dev/null +++ b/frontend/src/app/components/toast/toast.types.ts @@ -0,0 +1,9 @@ +export type ToastVariant = 'success' | 'error' | 'warning'; + +export interface ToastItem { + id: string; + variant: ToastVariant; + title: string; + description?: string; + durationMs: number; +} diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index 73b73b9..76a1bb6 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -35,7 +35,7 @@ interface Article { styleUrls: ['./home.css'], }) export class HomeComponent implements OnInit { - private router = inject(Router); + private readonly router = inject(Router); readonly ImagePlus = ImagePlus; readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; From 56705baa8e7594dbc5018a81d3041a58784326bc Mon Sep 17 00:00:00 2001 From: Sarah Domingos <92494941+sarahdomingos@users.noreply.github.com> Date: Sun, 31 May 2026 15:11:10 -0300 Subject: [PATCH 42/69] =?UTF-8?q?hotfix:=20corre=C3=A7=C3=A3o=20de=20bug?= =?UTF-8?q?=20no=20step=203=20do=20forms=20da=20CheckinComponent=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/features/checkin/checkin.html | 4 +- frontend/src/app/features/checkin/checkin.ts | 80 ++++++++++++++++--- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/features/checkin/checkin.html b/frontend/src/app/features/checkin/checkin.html index 0c9a2a0..8a36fd0 100644 --- a/frontend/src/app/features/checkin/checkin.html +++ b/frontend/src/app/features/checkin/checkin.html @@ -30,10 +30,10 @@

Check-in

[form]="symptomsForm" > - + > { @@ -128,10 +133,10 @@ export class CheckinComponent { return; case 2: - // if (this.hasNoSymptomsSelected()) { - // this.currentStep.set(4); - // return; - // } + if (this.hasNoSymptomsSelected()) { + this.currentStep.set(4); + return; + } this.currentStep.set(3); return; @@ -148,10 +153,10 @@ export class CheckinComponent { prevStep(): void { switch (this.currentStep()) { case 4: - // if (this.hasNoSymptomsSelected()) { - // this.currentStep.set(2); - // return; - // } + if (this.hasNoSymptomsSelected()) { + this.currentStep.set(2); + return; + } this.currentStep.set(3); return; @@ -182,6 +187,8 @@ export class CheckinComponent { selectedSymptoms: rawValue.symptoms.selectedSymptoms, }, }; + + console.log('Payload final do check-in:', payload); this.router.navigate(['home']); } @@ -203,4 +210,53 @@ export class CheckinComponent { return this.feelingForm; } } -} + + private hasNoSymptomsSelected(): boolean { + const selectedSymptoms = + this.symptomsForm.get('selectedSymptoms')?.value ?? []; + + return selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); + } + + private setupCurrentStepValidationWatcher(): void { + effect(() => { + const step = this.currentStep(); + const currentGroup = this.getStepForm(step); + + this.stepStatusSubscription?.unsubscribe(); + this.isCurrentStepInvalid.set(currentGroup.invalid); + + this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { + this.isCurrentStepInvalid.set(currentGroup.invalid); + }); + }); + } + + private setupIntensityConditionalValidation(): void { + const selectedSymptomsControl = this.symptomsForm.get('selectedSymptoms'); + const intensityScaleControl = this.intensityForm.get('scale'); + + this.applyIntensityValidation(); + + this.symptomsSelectionSubscription = selectedSymptomsControl?.valueChanges.subscribe(() => { + this.applyIntensityValidation(); + }); + } + + private applyIntensityValidation(): void { + const intensityScaleControl = this.intensityForm.get('scale'); + + if (!intensityScaleControl) { + return; + } + + if (this.hasNoSymptomsSelected()) { + intensityScaleControl.clearValidators(); + intensityScaleControl.setValue(null, { emitEvent: false }); + } else { + intensityScaleControl.setValidators([Validators.required]); + } + + intensityScaleControl.updateValueAndValidity({ emitEvent: true }); + } +} \ No newline at end of file From 90dc0c495079f9397884739851575b2ee9a144ab Mon Sep 17 00:00:00 2001 From: Leila Biggi <87096464+lawtherea@users.noreply.github.com> Date: Sun, 31 May 2026 20:49:46 -0300 Subject: [PATCH 43/69] [PEQ-23]: onboarding screen (#52) * [PEQ-23]: onboarding screen * fix: pequi logo doesnt show up when menu is collapsed --- frontend/public/assets/logo-purple.svg | 17 ++ frontend/public/assets/logo.svg | 17 ++ frontend/src/app/app.routes.ts | 6 +- frontend/src/app/components/menu/menu.html | 29 +++- frontend/src/app/components/menu/menu.spec.ts | 15 ++ .../auth/services/auth-service.spec.ts | 6 +- .../features/auth/services/auth-service.ts | 2 +- .../app/features/onboarding/onboarding.html | 155 ++++++++++++++++++ .../features/onboarding/onboarding.spec.ts | 109 ++++++++++++ .../src/app/features/onboarding/onboarding.ts | 30 ++++ 10 files changed, 371 insertions(+), 15 deletions(-) create mode 100644 frontend/public/assets/logo-purple.svg create mode 100644 frontend/public/assets/logo.svg create mode 100644 frontend/src/app/features/onboarding/onboarding.html create mode 100644 frontend/src/app/features/onboarding/onboarding.spec.ts create mode 100644 frontend/src/app/features/onboarding/onboarding.ts diff --git a/frontend/public/assets/logo-purple.svg b/frontend/public/assets/logo-purple.svg new file mode 100644 index 0000000..62d0154 --- /dev/null +++ b/frontend/public/assets/logo-purple.svg @@ -0,0 +1,17 @@ + + diff --git a/frontend/public/assets/logo.svg b/frontend/public/assets/logo.svg new file mode 100644 index 0000000..5aeae44 --- /dev/null +++ b/frontend/public/assets/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5d0adf3..1fed051 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -13,11 +13,13 @@ import { PhotoRegister } from './features/photo-register/photo-register'; import { RegisterAppointmentComponent } from './features/appointments/register-appointment/register-appointment'; import { Login } from './features/login/login'; import { Register } from './features/register/register'; +import { Onboarding } from './features/onboarding/onboarding'; import { authGuard } from './features/auth/guards/auth-guard'; export const routes: Routes = [ - { path: 'login', component: Login }, - { path: 'register', component: Register }, + { path: '', pathMatch: 'full', component: Onboarding, title: 'Bem-vindo' }, + { path: 'login', component: Login }, + { path: 'register', component: Register }, { path: '', component: AppShellComponent, diff --git a/frontend/src/app/components/menu/menu.html b/frontend/src/app/components/menu/menu.html index 9b879fd..0827b77 100644 --- a/frontend/src/app/components/menu/menu.html +++ b/frontend/src/app/components/menu/menu.html @@ -11,16 +11,27 @@ [class.px-6]="!isCollapsed()" [class.px-3]="isCollapsed()" > -
- +
- Pequi - + + + Pequi + +
diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts index 7b83146..a3d91a7 100644 --- a/frontend/src/app/features/login/login.ts +++ b/frontend/src/app/features/login/login.ts @@ -3,6 +3,7 @@ import { Component, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { AuthService } from '../auth/services/auth-service'; +import { ToastService } from '../../components/toast/toast.service'; @Component({ selector: 'app-login', @@ -11,46 +12,66 @@ import { AuthService } from '../auth/services/auth-service'; templateUrl: './login.html', styleUrl: './login.css', }) - export class Login { private readonly fb = inject(FormBuilder); private readonly authService = inject(AuthService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly toastService = inject(ToastService); - errorMessage = ''; isSubmitting = false; + ngOnInit(): void { + const registered = this.route.snapshot.queryParamMap.get('registered'); + + if (registered === 'true') { + this.toastService.success( + 'Cadastro realizado com sucesso.', + 'Agora faça login para continuar.' + ); + } + } + form = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required]], }); -submit(): void { - this.errorMessage = ''; - this.form.markAllAsTouched(); - if (this.form.invalid) { - return; - } + submit(): void { + this.form.markAllAsTouched(); - this.isSubmitting = true; - this.authService - .login({ - email: this.form.value.email ?? '', - password: this.form.value.password ?? '', - }) - .subscribe({ - next: (response) => { - const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/home'; - void this.router.navigateByUrl(returnUrl); - }, - error: (error) => { - this.errorMessage = error?.error?.message ?? 'E-mail ou senha inválidos.'; - this.isSubmitting = false; - }, - complete: () => { - this.isSubmitting = false; - }, - }); -} + if (this.form.invalid) { + this.toastService.warning( + 'Formulário inválido', + 'Preencha e-mail e senha corretamente.' + ); + return; + } + + this.isSubmitting = true; + + this.authService + .login({ + email: this.form.value.email ?? '', + password: this.form.value.password ?? '', + }) + .subscribe({ + next: () => { + this.toastService.success('Login realizado com sucesso.'); + const returnUrl = + this.route.snapshot.queryParamMap.get('returnUrl') ?? '/home'; + void this.router.navigateByUrl(returnUrl); + }, + error: (error) => { + const message = + error?.error?.message ?? 'E-mail ou senha inválidos.'; + + this.toastService.error('Falha no login', message); + this.isSubmitting = false; + }, + complete: () => { + this.isSubmitting = false; + }, + }); + } } \ No newline at end of file diff --git a/frontend/src/app/features/register/register.html b/frontend/src/app/features/register/register.html index 4f4476f..672040d 100644 --- a/frontend/src/app/features/register/register.html +++ b/frontend/src/app/features/register/register.html @@ -31,9 +31,6 @@

Crie sua conta

/> -

{{ errorMessage }}

-

{{ successMessage }}

- diff --git a/frontend/src/app/features/register/register.ts b/frontend/src/app/features/register/register.ts index 5e69dea..7eef5b5 100644 --- a/frontend/src/app/features/register/register.ts +++ b/frontend/src/app/features/register/register.ts @@ -1,8 +1,9 @@ import { CommonModule } from '@angular/common'; import { Component, inject } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AbstractControl, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { AuthService } from '../auth/services/auth-service'; +import { ToastService } from '../../components/toast/toast.service'; @Component({ selector: 'app-register', @@ -15,9 +16,8 @@ export class Register { private readonly fb = inject(FormBuilder); private readonly authService = inject(AuthService); private readonly router = inject(Router); + private readonly toastService = inject(ToastService); - errorMessage = ''; - successMessage = ''; isSubmitting = false; form = this.fb.group({ @@ -27,41 +27,94 @@ export class Register { confirmPassword: ['', [Validators.required]], }); -submit(): void { - this.errorMessage = ''; - this.successMessage = ''; - this.form.markAllAsTouched(); + submit(): void { + this.form.markAllAsTouched(); - if (this.form.invalid) { - return; + if (this.form.invalid) { + this.toastService.warning( + 'Formulário inválido', + this.getFormErrorMessage() + ); + return; + } + + if ((this.form.value.password ?? '') !== (this.form.value.confirmPassword ?? '')) { + this.toastService.warning( + 'Senhas diferentes', + 'As senhas informadas não coincidem.' + ); + return; + } + + this.isSubmitting = true; + + this.authService + .register({ + full_name: this.form.value.full_name ?? '', + email: this.form.value.email ?? '', + password: this.form.value.password ?? '', + }) + .subscribe({ + next: () => { + void this.router.navigate(['/login'], { + queryParams: { registered: 'true' }, + }); + }, + error: (error) => { + const message = + error?.error?.detail || + error?.error?.message || + 'Não foi possível cadastrar.'; + + this.toastService.error('Erro no cadastro', message); + this.isSubmitting = false; + }, + complete: () => { + this.isSubmitting = false; + }, + }); } - if ((this.form.value.password ?? '') !== (this.form.value.confirmPassword ?? '')) { - this.errorMessage = 'As senhas não coincidem.'; - return; + private getFormErrorMessage(): string { + const fullNameControl = this.form.get('full_name'); + const emailControl = this.form.get('email'); + const passwordControl = this.form.get('password'); + const confirmPasswordControl = this.form.get('confirmPassword'); + + if (this.hasError(fullNameControl, 'required')) { + return 'Informe seu nome completo.'; + } + + if (this.hasError(fullNameControl, 'minlength')) { + const requiredLength = fullNameControl?.errors?.['minlength']?.requiredLength; + return `O nome completo deve ter pelo menos ${requiredLength} caracteres.`; + } + + if (this.hasError(emailControl, 'required')) { + return 'Informe seu e-mail.'; + } + + if (this.hasError(emailControl, 'email')) { + return 'Informe um e-mail válido.'; + } + + if (this.hasError(passwordControl, 'required')) { + return 'Informe sua senha.'; + } + + if (this.hasError(passwordControl, 'minlength')) { + const requiredLength = passwordControl?.errors?.['minlength']?.requiredLength; + return `A senha deve ter pelo menos ${requiredLength} caracteres.`; + } + + if (this.hasError(confirmPasswordControl, 'required')) { + return 'Confirme sua senha.'; + } + + return 'Revise os campos obrigatórios antes de continuar.'; + } + + private hasError(control: AbstractControl | null, errorKey: string): boolean { + return !!control?.touched && !!control?.errors?.[errorKey]; } - this.isSubmitting = true; - - this.authService.register({ - full_name: this.form.value.full_name ?? '', - email: this.form.value.email ?? '', - password: this.form.value.password ?? '', - }).subscribe({ - next: () => { - this.successMessage = 'Cadastro realizado com sucesso.'; - void this.router.navigate(['/login']); - }, - error: (error) => { - console.error('erro no cadastro:', error); - this.errorMessage = - error?.error?.detail || - error?.error?.message || - 'Não foi possível cadastrar.'; - this.isSubmitting = false; - }, - complete: () => { - this.isSubmitting = false; - }, - }); -} } \ No newline at end of file From b8d6baba136e4eab9457bf3aef7faa36a8736ef4 Mon Sep 17 00:00:00 2001 From: Rafael Luciano <74800037+rafaellucian0@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:49:12 -0300 Subject: [PATCH 45/69] PEQ-85: Implement M10 Integrations (#51) * feat: implement integration service layer with WhatsApp, AI, and object storage support including FastAPI dependency injection and testing infrastructure * fix: standardize comment punctuation and improve formatting in AI and WhatsApp client integration tests * fix: fix stateless external integrations for WhatsApp, object storage, and AI services with full dependency injection and test coverage. Co-authored-by: Matheus Ryan --- backend/.env.example | 4 + backend/src/pequi/config.py | 15 +- backend/src/pequi/core/dependencies.py | 36 +++ backend/src/pequi/integrations/__init__.py | 12 + backend/src/pequi/integrations/ai_client.py | 125 ++++++++- .../src/pequi/integrations/object_storage.py | 183 ++++++++++++ backend/src/pequi/integrations/whatsapp.py | 243 ++++++++++++++++ backend/src/pequi/main.py | 7 +- .../src/pequi/services/ai_feedback_service.py | 32 +-- .../pequi/services/notification_service.py | 34 ++- backend/tests/conftest.py | 39 ++- backend/tests/unit/test_ai_client.py | 215 +++++++++++++++ .../tests/unit/test_notification_service.py | 30 ++ .../tests/unit/test_object_storage_client.py | 205 ++++++++++++++ backend/tests/unit/test_whatsapp_client.py | 261 ++++++++++++++++++ 15 files changed, 1401 insertions(+), 40 deletions(-) create mode 100644 backend/src/pequi/integrations/object_storage.py create mode 100644 backend/src/pequi/integrations/whatsapp.py create mode 100644 backend/tests/unit/test_ai_client.py create mode 100644 backend/tests/unit/test_notification_service.py create mode 100644 backend/tests/unit/test_object_storage_client.py create mode 100644 backend/tests/unit/test_whatsapp_client.py diff --git a/backend/.env.example b/backend/.env.example index 59e26d2..ca36000 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -65,8 +65,12 @@ WHATSAPP_PROVIDER=twilio # twilio | evolution TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_WHATSAPP_FROM=whatsapp:+14155238886 +# JSON mapping from logical template name to Twilio ContentSid (HX...) +TWILIO_CONTENT_SIDS={} EVOLUTION_API_URL= EVOLUTION_API_KEY= +EVOLUTION_INSTANCE= +EVOLUTION_TEMPLATE_LANGUAGE=pt_BR # ── Anthropic ────────────────────────────────────────────────────────────────── ANTHROPIC_API_KEY= diff --git a/backend/src/pequi/config.py b/backend/src/pequi/config.py index d2045b3..1a78881 100644 --- a/backend/src/pequi/config.py +++ b/backend/src/pequi/config.py @@ -1,7 +1,8 @@ +import json from functools import lru_cache from typing import Literal -from pydantic import field_validator +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy.engine import make_url @@ -41,8 +42,11 @@ class Settings(BaseSettings): TWILIO_ACCOUNT_SID: str = "" TWILIO_AUTH_TOKEN: str = "" TWILIO_WHATSAPP_FROM: str = "whatsapp:+14155238886" + TWILIO_CONTENT_SIDS: dict[str, str] = Field(default_factory=dict) EVOLUTION_API_URL: str = "" EVOLUTION_API_KEY: str = "" + EVOLUTION_INSTANCE: str = "" + EVOLUTION_TEMPLATE_LANGUAGE: str = "pt_BR" # Anthropic ANTHROPIC_API_KEY: str = "" @@ -55,8 +59,15 @@ class Settings(BaseSettings): @classmethod def parse_origins(cls, v: str | list[str]) -> list[str]: if isinstance(v, str): - import json + return json.loads(v) + return v + @field_validator("TWILIO_CONTENT_SIDS", mode="before") + @classmethod + def parse_twilio_content_sids(cls, v: str | dict[str, str]) -> dict[str, str]: + if isinstance(v, str): + if not v.strip(): + return {} return json.loads(v) return v diff --git a/backend/src/pequi/core/dependencies.py b/backend/src/pequi/core/dependencies.py index d535342..1ed0a92 100644 --- a/backend/src/pequi/core/dependencies.py +++ b/backend/src/pequi/core/dependencies.py @@ -6,6 +6,7 @@ """ from collections.abc import AsyncGenerator +from functools import lru_cache from uuid import UUID from fastapi import Depends @@ -16,6 +17,7 @@ from pequi.core.auth import TOKEN_TYPE_ACCESS, JWTError, decode_token from pequi.core.exceptions import ForbiddenError, UnauthorizedError from pequi.database import get_db as _get_db +from pequi.integrations import AIClient, ObjectStorageClient, WhatsAppClient, get_anthropic_client from pequi.repositories.patient_repo import PatientRepository from pequi.services.storage_service import FakeStorageService, StorageService from pequi.use_cases.get_patient_profile import GetPatientProfileUseCase @@ -94,6 +96,35 @@ async def get_update_patient_profile_use_case( return UpdatePatientProfileUseCase(PatientRepository(session)) +@lru_cache +def get_cached_whatsapp_client() -> WhatsAppClient: + """Return the shared WhatsApp client used by FastAPI dependencies.""" + return WhatsAppClient() + + +async def get_whatsapp_client() -> WhatsAppClient: + """Dependency that returns a configured WhatsApp client.""" + return get_cached_whatsapp_client() + + +async def close_cached_whatsapp_client() -> None: + """Close the shared WhatsApp client, if it has been created.""" + if get_cached_whatsapp_client.cache_info().currsize == 0: + return + await get_cached_whatsapp_client().close() + get_cached_whatsapp_client.cache_clear() + + +async def get_object_storage_client() -> ObjectStorageClient: + """Dependency that returns a configured object storage client.""" + return ObjectStorageClient() + + +async def get_ai_client() -> AIClient | None: + """Dependency that returns a configured AI client or None if unavailable.""" + return get_anthropic_client() + + def get_storage_service() -> StorageService: settings = get_settings() return FakeStorageService(base_url=settings.STORAGE_PUBLIC_URL) @@ -109,5 +140,10 @@ def get_storage_service() -> StorageService: "get_current_admin", "get_patient_profile_use_case", "get_update_patient_profile_use_case", + "get_cached_whatsapp_client", + "get_whatsapp_client", + "close_cached_whatsapp_client", + "get_object_storage_client", + "get_ai_client", "get_storage_service", ] diff --git a/backend/src/pequi/integrations/__init__.py b/backend/src/pequi/integrations/__init__.py index e69de29..88683eb 100644 --- a/backend/src/pequi/integrations/__init__.py +++ b/backend/src/pequi/integrations/__init__.py @@ -0,0 +1,12 @@ +"""Integration clients for external services.""" + +from pequi.integrations.ai_client import AIClient, get_anthropic_client +from pequi.integrations.object_storage import ObjectStorageClient +from pequi.integrations.whatsapp import WhatsAppClient + +__all__ = [ + "AIClient", + "get_anthropic_client", + "ObjectStorageClient", + "WhatsAppClient", +] diff --git a/backend/src/pequi/integrations/ai_client.py b/backend/src/pequi/integrations/ai_client.py index 9c7efab..97cc267 100644 --- a/backend/src/pequi/integrations/ai_client.py +++ b/backend/src/pequi/integrations/ai_client.py @@ -1,14 +1,129 @@ -"""Cliente Anthropic — usado pelo worker de feedback de IA.""" +"""Anthropic AI client - used by the feedback worker.""" + +import logging from anthropic import AsyncAnthropic -from pequi.config import get_settings +from pequi.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +SYSTEM_PROMPT = ( + "Você é um assistente de saúde empático que fornece feedback sobre " + "check-ins de pacientes.\n" + "Sua resposta deve ser:\n" + "- Em português brasileiro\n" + "- Empática e acolhedora\n" + "- Focada em apoio emocional e orientações gerais\n" + "- SEM fazer diagnósticos clínicos ou recomendar tratamentos específicos\n" + "- Encorajando o paciente a continuar o acompanhamento com profissionais de saúde\n\n" + "Responda de forma concisa (máximo 500 caracteres)." +) + + +class AIClient: + """Anthropic Claude AI client for generating check-in feedback.""" + + def __init__( + self, + api_key: str | None = None, + model: str | None = None, + settings: Settings | None = None, + client: AsyncAnthropic | None = None, + ) -> None: + """Initialize AI client. + + Args: + api_key: Anthropic API key. Defaults to settings.ANTHROPIC_API_KEY. + model: Model name. Defaults to settings.ANTHROPIC_MODEL. + settings: Optional settings for testing. + client: Optional Anthropic client for testing. + """ + self.settings = settings or get_settings() + self.api_key = (api_key or self.settings.ANTHROPIC_API_KEY).strip() + self.model = model or self.settings.ANTHROPIC_MODEL + + if client: + self.client = client + elif not self.api_key: + logger.warning("Anthropic API key not configured") + self.client = None + else: + self.client = AsyncAnthropic(api_key=self.api_key) + + async def generate_checkin_feedback( + self, + symptoms: list[str], + intensity: int, + mood: str, + history_summary: str = "", + ) -> str: + """Generate empathetic feedback for a check-in. + + Args: + symptoms: List of symptoms reported + intensity: Symptom intensity (1-10) + mood: Patient's mood description + history_summary: Optional summary of patient history + + Returns: + Generated feedback text (truncated to 500 characters) + + Note: + If the API call fails, returns an empty string and logs an error. + The failure does not raise an exception to avoid disrupting the worker flow. + """ + if not self.client: + logger.warning("Anthropic client not initialized") + return "" + + try: + symptoms_text = ", ".join(symptoms) if symptoms else "nenhum sintoma relatado" + + user_message = f"""Sintomas: {symptoms_text} +Intensidade: {intensity}/10 +Humor: {mood} +Histórico: {history_summary if history_summary else "Não disponível"} + +Forneça um feedback empático e acolhedor para este paciente.""" + + response = await self.client.messages.create( + model=self.model, + max_tokens=500, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + timeout=30.0, + ) + + feedback = response.content[0].text.strip() + + if len(feedback) > 500: + feedback = feedback[:497] + "..." + + return feedback + + except Exception as e: + logger.error( + "Failed to generate AI feedback: %s", + str(e), + extra={ + "symptom_count": len(symptoms), + "intensity": intensity, + "error_type": type(e).__name__, + }, + ) + return "" -settings = get_settings() +def get_anthropic_client() -> AIClient | None: + """Factory function to get an AI client instance. -def get_anthropic_client() -> AsyncAnthropic | None: + Returns: + AIClient instance or None if API key is not configured + """ + settings = get_settings() api_key = settings.ANTHROPIC_API_KEY.strip() if not api_key: return None - return AsyncAnthropic(api_key=api_key) + return AIClient(api_key=api_key, model=settings.ANTHROPIC_MODEL) diff --git a/backend/src/pequi/integrations/object_storage.py b/backend/src/pequi/integrations/object_storage.py new file mode 100644 index 0000000..e8cef7e --- /dev/null +++ b/backend/src/pequi/integrations/object_storage.py @@ -0,0 +1,183 @@ +"""Object storage client — S3-compatible (MinIO / Cloudflare R2).""" + +import logging +from typing import Literal + +import aiobotocore.session + +from pequi.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +class ObjectStorageClient: + """S3-compatible object storage client using aiobotocore.""" + + def __init__( + self, + bucket: str | None = None, + endpoint_url: str | None = None, + access_key: str | None = None, + secret_key: str | None = None, + region: str | None = None, + public_url: str | None = None, + settings: Settings | None = None, + ) -> None: + """Initialize object storage client. + + Args: + bucket: Bucket name. Defaults to settings.STORAGE_BUCKET_IMAGES. + endpoint_url: S3 endpoint URL. Defaults to settings.STORAGE_ENDPOINT. + access_key: Access key. Defaults to settings.STORAGE_ACCESS_KEY. + secret_key: Secret key. Defaults to settings.STORAGE_SECRET_KEY. + region: Region. Defaults to settings.STORAGE_REGION. + public_url: Public URL base. Defaults to settings.STORAGE_PUBLIC_URL. + """ + settings = settings or get_settings() + self.bucket = bucket or settings.STORAGE_BUCKET_IMAGES + self.endpoint_url = endpoint_url or settings.STORAGE_ENDPOINT + self._access_key = access_key or settings.STORAGE_ACCESS_KEY + self._secret_key = secret_key or settings.STORAGE_SECRET_KEY + self.region = region or settings.STORAGE_REGION + self.public_url = public_url if public_url is not None else settings.STORAGE_PUBLIC_URL + + self._session = aiobotocore.session.get_session() + + async def _get_client(self): + """Create a boto3 client.""" + return self._session.create_client( + "s3", + endpoint_url=self.endpoint_url, + aws_access_key_id=self._access_key, + aws_secret_access_key=self._secret_key, + region_name=self.region, + ) + + async def upload( + self, + key: str, + data: bytes, + content_type: str = "application/octet-stream", + ) -> str: + """Upload data to object storage. + + Args: + key: Object key (path within bucket) + data: Binary data to upload + content_type: MIME type of the data + + Returns: + Public URL of the uploaded object + """ + try: + async with await self._get_client() as client: + await client.put_object( + Bucket=self.bucket, + Key=key, + Body=data, + ContentType=content_type, + ) + + if self.public_url: + return f"{self.public_url}/{self.bucket}/{key}" + return await self.generate_presigned_url(key) + except Exception as e: + logger.error( + "Failed to upload object to storage: %s", + str(e), + extra={"key": key, "bucket": self.bucket}, + ) + raise + + async def generate_presigned_url( + self, + key: str, + expires: int = 3600, + method: Literal["get_object", "put_object"] = "get_object", + ) -> str: + """Generate a presigned URL for an object. + + Args: + key: Object key + expires: URL expiration time in seconds (default: 1 hour) + method: S3 operation (get_object or put_object) + + Returns: + Presigned URL + """ + try: + async with await self._get_client() as client: + url = await client.generate_presigned_url( + method, + Params={"Bucket": self.bucket, "Key": key}, + ExpiresIn=expires, + ) + return url + except Exception as e: + logger.error( + "Failed to generate presigned URL: %s", + str(e), + extra={"key": key, "bucket": self.bucket}, + ) + raise + + async def delete(self, key: str) -> None: + """Delete an object from storage. + + Args: + key: Object key to delete + """ + try: + async with await self._get_client() as client: + await client.delete_object(Bucket=self.bucket, Key=key) + logger.info( + "Deleted object from storage", + extra={"key": key, "bucket": self.bucket}, + ) + except Exception as e: + logger.error( + "Failed to delete object from storage: %s", + str(e), + extra={"key": key, "bucket": self.bucket}, + ) + # Deletion is best-effort for account removal flows and must not + # interrupt the higher-level process if storage is unavailable. + return None + + async def exists(self, key: str) -> bool: + """Check if an object exists. + + Args: + key: Object key + + Returns: + True if object exists, False otherwise + """ + try: + async with await self._get_client() as client: + await client.head_object(Bucket=self.bucket, Key=key) + return True + except Exception: + return False + + async def get(self, key: str) -> bytes: + """Get object data. + + Args: + key: Object key + + Returns: + Object data as bytes + """ + try: + async with await self._get_client() as client: + response = await client.get_object(Bucket=self.bucket, Key=key) + async with response["Body"] as stream: + return await stream.read() + except Exception as e: + logger.error( + "Failed to get object from storage: %s", + str(e), + extra={"key": key, "bucket": self.bucket}, + ) + raise diff --git a/backend/src/pequi/integrations/whatsapp.py b/backend/src/pequi/integrations/whatsapp.py new file mode 100644 index 0000000..5926bc2 --- /dev/null +++ b/backend/src/pequi/integrations/whatsapp.py @@ -0,0 +1,243 @@ +"""WhatsApp client - supports Twilio and Evolution API.""" + +import asyncio +import hashlib +import json +import logging +import re +from typing import Literal + +import httpx + +from pequi.config import Settings, get_settings + +logger = logging.getLogger(__name__) + +_E164_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$") + + +def _get_phone_hash(to: str) -> str: + return hashlib.sha256(to.encode()).hexdigest()[:8] + + +class WhatsAppClient: + """WhatsApp client supporting Twilio and Evolution API providers.""" + + def __init__( + self, + provider: Literal["twilio", "evolution"] | None = None, + http_client: httpx.AsyncClient | None = None, + settings: Settings | None = None, + ) -> None: + """Initialize WhatsApp client. + + Args: + provider: WhatsApp provider (twilio or evolution). Defaults to settings. + http_client: Optional httpx client for testing. + settings: Optional settings for testing. + """ + self.settings = settings or get_settings() + self.provider = provider or self.settings.WHATSAPP_PROVIDER + self._http_client_owned = http_client is None + self.http_client = http_client or httpx.AsyncClient(timeout=30.0) + + async def send_message(self, to: str, body: str) -> str: + """Send a WhatsApp message. + + Args: + to: Phone number in E.164 format (e.g., +5511999999999) + body: Message content + + Returns: + Message ID from the provider + + Raises: + ValueError: If provider credentials are not configured or provider is unsupported + """ + if not _E164_PATTERN.match(to): + raise ValueError("Phone number must be in E.164 format") + for attempt in range(3): + try: + if self.provider == "twilio": + return await self._send_twilio_message(to, body) + if self.provider == "evolution": + return await self._send_evolution_message(to, body) + raise ValueError(f"Unsupported provider: {self.provider}") + except ValueError: + raise + except Exception as e: + if attempt == 2: + logger.warning( + "Failed to send WhatsApp message after 3 attempts: %s", + str(e), + extra={ + "to_hash": _get_phone_hash(to), + "provider": self.provider, + }, + ) + return "" + await asyncio.sleep(2**attempt) + + return "" + + async def send_template(self, to: str, template: str, params: dict[str, str]) -> str: + """Send a WhatsApp template message. + + Args: + to: Phone number in E.164 format + template: Template name + params: Template parameters + + Returns: + Message ID from the provider + + Raises: + ValueError: If provider credentials are not configured or provider is unsupported + """ + if not _E164_PATTERN.match(to): + raise ValueError("Phone number must be in E.164 format") + + for attempt in range(3): + try: + if self.provider == "twilio": + return await self._send_twilio_template(to, template, params) + if self.provider == "evolution": + return await self._send_evolution_template(to, template, params) + raise ValueError(f"Unsupported provider: {self.provider}") + except ValueError: + raise + except Exception as e: + if attempt == 2: + logger.warning( + "Failed to send WhatsApp template after 3 attempts: %s", + str(e), + extra={ + "to_hash": _get_phone_hash(to), + "template": template, + "provider": self.provider, + }, + ) + return "" + await asyncio.sleep(2**attempt) + + return "" + + async def _send_twilio_message(self, to: str, body: str) -> str: + """Send message via Twilio API.""" + if not self.settings.TWILIO_ACCOUNT_SID or not self.settings.TWILIO_AUTH_TOKEN: + raise ValueError("Twilio credentials not configured") + + url = ( + "https://api.twilio.com/2010-04-01/Accounts/" + f"{self.settings.TWILIO_ACCOUNT_SID}/Messages.json" + ) + auth = (self.settings.TWILIO_ACCOUNT_SID, self.settings.TWILIO_AUTH_TOKEN) + data = { + "From": self.settings.TWILIO_WHATSAPP_FROM, + "To": f"whatsapp:{to}", + "Body": body, + } + + response = await self.http_client.post(url, auth=auth, data=data) + response.raise_for_status() + result = response.json() + return result.get("sid", "") + + async def _send_twilio_template(self, to: str, template: str, _params: dict[str, str]) -> str: + """Send template via Twilio API.""" + if not self.settings.TWILIO_ACCOUNT_SID or not self.settings.TWILIO_AUTH_TOKEN: + raise ValueError("Twilio credentials not configured") + content_sid = self.settings.TWILIO_CONTENT_SIDS.get(template) + if not content_sid: + raise ValueError(f"Twilio content SID not configured for template: {template}") + + url = ( + "https://api.twilio.com/2010-04-01/Accounts/" + f"{self.settings.TWILIO_ACCOUNT_SID}/Messages.json" + ) + auth = (self.settings.TWILIO_ACCOUNT_SID, self.settings.TWILIO_AUTH_TOKEN) + data = { + "From": self.settings.TWILIO_WHATSAPP_FROM, + "To": f"whatsapp:{to}", + "ContentSid": content_sid, + "ContentVariables": json.dumps(_params, ensure_ascii=False, separators=(",", ":")), + } + + response = await self.http_client.post(url, auth=auth, data=data) + response.raise_for_status() + result = response.json() + return result.get("sid", "") + + async def _send_evolution_message(self, to: str, body: str) -> str: + """Send message via Evolution API.""" + if ( + not self.settings.EVOLUTION_API_URL + or not self.settings.EVOLUTION_API_KEY + or not self.settings.EVOLUTION_INSTANCE + ): + raise ValueError("Evolution API credentials not configured") + + url = ( + f"{self.settings.EVOLUTION_API_URL}/message/sendText/{self.settings.EVOLUTION_INSTANCE}" + ) + headers = { + "Content-Type": "application/json", + "apikey": self.settings.EVOLUTION_API_KEY, + } + data = {"number": to, "text": body} + + response = await self.http_client.post(url, headers=headers, json=data) + response.raise_for_status() + result = response.json() + return result.get("key", {}).get("id", "") + + async def _send_evolution_template( + self, to: str, template: str, _params: dict[str, str] + ) -> str: + """Send template via Evolution API.""" + if ( + not self.settings.EVOLUTION_API_URL + or not self.settings.EVOLUTION_API_KEY + or not self.settings.EVOLUTION_INSTANCE + ): + raise ValueError("Evolution API credentials not configured") + + url = ( + f"{self.settings.EVOLUTION_API_URL}/message/sendTemplate/" + f"{self.settings.EVOLUTION_INSTANCE}" + ) + headers = { + "Content-Type": "application/json", + "apikey": self.settings.EVOLUTION_API_KEY, + } + parameters = [ + {"type": "text", "text": value} + for _, value in sorted(_params.items(), key=lambda item: item[0]) + ] + data = { + "number": to, + "name": template, + "language": {"code": self.settings.EVOLUTION_TEMPLATE_LANGUAGE}, + "components": [ + { + "type": "body", + "parameters": parameters, + } + ], + } + + response = await self.http_client.post(url, headers=headers, json=data) + response.raise_for_status() + result = response.json() + return result.get("key", {}).get("id", "") + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def close(self) -> None: + """Close the HTTP client if this client owns it.""" + if self._http_client_owned: + await self.http_client.aclose() diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index cd6769f..61bd37e 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -34,7 +34,12 @@ def _init_sentry() -> None: async def lifespan(app: FastAPI) -> AsyncIterator[None]: configure_logging() _init_sentry() - yield + try: + yield + finally: + from pequi.core.dependencies import close_cached_whatsapp_client + + await close_cached_whatsapp_client() def create_app() -> FastAPI: diff --git a/backend/src/pequi/services/ai_feedback_service.py b/backend/src/pequi/services/ai_feedback_service.py index dc674f9..f73a18f 100644 --- a/backend/src/pequi/services/ai_feedback_service.py +++ b/backend/src/pequi/services/ai_feedback_service.py @@ -4,7 +4,7 @@ from pequi.config import get_settings from pequi.core.logging import get_logger -from pequi.integrations.ai_client import get_anthropic_client +from pequi.integrations.ai_client import AIClient, get_anthropic_client from pequi.models.checkin import Checkin logger = get_logger(__name__) @@ -24,39 +24,31 @@ class AIFeedbackService: + def __init__(self, ai_client: AIClient | None = None) -> None: + self.ai_client = ai_client or get_anthropic_client() + async def generate_feedback(self, checkin: Checkin) -> str: """Gera texto de apoio clínico sem dados pessoais identificáveis.""" - client = get_anthropic_client() - if client is None: + if self.ai_client is None: logger.info("ai_feedback.skipped", reason="no_api_key") return _FALLBACK_FEEDBACK symptom_names = [s.name for s in checkin.symptoms] if checkin.symptoms else [] - prompt = ( - "Você é um assistente de saúde para pacientes com hanseníase. " - "Gere um parágrafo curto (máx. 3 frases) de orientação empática em português. " - "NÃO inclua nome, CPF, e-mail, endereço ou qualquer dado pessoal. " - "Use apenas: humor, intensidade de sintomas (0-10) e nomes genéricos de sintomas.\n\n" - f"Humor: {checkin.mood.value}\n" - f"Intensidade: {checkin.symptom_intensity}/10\n" - f"Sintomas relatados: {', '.join(symptom_names) or 'nenhum específico'}\n" - ) try: - response = await client.messages.create( - model=settings.ANTHROPIC_MODEL, - max_tokens=256, - messages=[{"role": "user", "content": prompt}], + feedback = await self.ai_client.generate_checkin_feedback( + symptoms=symptom_names, + intensity=checkin.symptom_intensity, + mood=checkin.mood.value, + history_summary="", ) - if not response.content: + if not feedback: return _FALLBACK_FEEDBACK - text = response.content[0].text.strip() # type: ignore[union-attr] + return self._sanitize(feedback) except Exception: logger.warning("ai_feedback.api_error", checkin_id=str(checkin.id)) return _FALLBACK_FEEDBACK - return self._sanitize(text) - def _sanitize(self, text: str) -> str: for pattern in _PII_PATTERNS: text = pattern.sub("[redacted]", text) diff --git a/backend/src/pequi/services/notification_service.py b/backend/src/pequi/services/notification_service.py index 9a81aa7..fa80632 100644 --- a/backend/src/pequi/services/notification_service.py +++ b/backend/src/pequi/services/notification_service.py @@ -3,18 +3,50 @@ from uuid import UUID from pequi.core.logging import get_logger +from pequi.integrations import WhatsAppClient logger = get_logger(__name__) class NotificationService: - async def send_feedback(self, patient_id: UUID, feedback: str) -> None: + def __init__(self, whatsapp_client: WhatsAppClient | None = None) -> None: + self.whatsapp_client = whatsapp_client + + async def send_feedback( + self, + patient_id: UUID, + feedback: str, + recipient_phone: str | None = None, + ) -> None: """Envia feedback de IA ao paciente (push/WhatsApp quando disponível).""" logger.info( "notification.feedback_queued", patient_id=str(patient_id), feedback_length=len(feedback), ) + if self.whatsapp_client is None: + return + + if recipient_phone is None: + logger.info( + "notification.whatsapp_skipped_no_phone", + patient_id=str(patient_id), + ) + return + + try: + message_id = await self.whatsapp_client.send_message(recipient_phone, feedback) + logger.info( + "notification.whatsapp_sent", + patient_id=str(patient_id), + message_sent=bool(message_id), + ) + except Exception as exc: + logger.warning( + "notification.whatsapp_failed", + patient_id=str(patient_id), + error_type=type(exc).__name__, + ) async def send_dose_reminder(self, patient_id: UUID) -> None: """Envia lembrete diário de dose ao paciente.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index dafdedb..b1ef433 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,20 +5,20 @@ from pathlib import Path import pytest +import sqlalchemy as sa from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool -import pequi.models # noqa: F401 — registra todas as tabelas no metadata antes do create_all -import pequi.models.article # noqa: F401 — ensure article models are registered -import pequi.models.community # noqa: F401 — ensure community models are registered +import pequi.models # noqa: F401 - register all tables before create_all +import pequi.models.article # noqa: F401 - ensure article models are registered +import pequi.models.community # noqa: F401 - ensure community models are registered from pequi.config import get_settings from pequi.core.dependencies import get_db from pequi.core.rate_limit import limiter, user_limiter from pequi.database import Base from pequi.main import app -# Disable rate limiting for tests limiter.enabled = False user_limiter.enabled = False @@ -46,12 +46,14 @@ def _xdist_shared_root(tmp_path_factory: pytest.TempPathFactory) -> Path: - """Diretório compartilhado entre workers do pytest-xdist.""" + """Shared directory across pytest-xdist workers.""" + return tmp_path_factory.getbasetemp().parent def _try_acquire_file_lock(lock_path: Path) -> bool: lock_path.parent.mkdir(parents=True, exist_ok=True) + try: fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.close(fd) @@ -66,10 +68,12 @@ def _release_file_lock(lock_path: Path) -> None: def _wait_until_ready(ready_path: Path, timeout: float = _DB_LOCK_TIMEOUT_SEC) -> None: deadline = time.monotonic() + timeout + while not ready_path.is_file(): if time.monotonic() >= deadline: msg = f"Timed out waiting for test database schema at {ready_path}" raise TimeoutError(msg) + time.sleep(_DB_POLL_INTERVAL_SEC) @@ -78,6 +82,15 @@ async def _reset_schema() -> None: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) + # Clear seed data inserted by migrations (body_areas, etc.). + try: + await conn.execute(sa.text("TRUNCATE TABLE body_areas CASCADE")) + await conn.execute(sa.text("TRUNCATE TABLE body_map_entries CASCADE")) + await conn.execute(sa.text("TRUNCATE TABLE body_area_history CASCADE")) + except sa.exc.ProgrammingError: + # Tables may not exist if migrations have not created them yet. + pass + @pytest.fixture(scope="session") def event_loop_policy(): @@ -85,16 +98,18 @@ def event_loop_policy(): if sys.platform == "win32": return asyncio.WindowsSelectorEventLoopPolicy() + return asyncio.DefaultEventLoopPolicy() @pytest.fixture(scope="session") async def create_tables(tmp_path_factory: pytest.TempPathFactory): - """Cria o schema uma vez por execução, mesmo com pytest-xdist (-n > 1). + """Create the schema once per test run, even with pytest-xdist (-n > 1). - Sem sincronização, cada worker chama create_all em paralelo e disputa - tipos ENUM no PostgreSQL (ex.: user_role_enum). + Without synchronization, workers call create_all in parallel and race on + PostgreSQL enum types, such as user_role_enum. """ + root = _xdist_shared_root(tmp_path_factory) lock_path = root / "pequi_test_db.lock" ready_path = root / "pequi_test_db.ready" @@ -117,11 +132,13 @@ async def create_tables(tmp_path_factory: pytest.TempPathFactory): @pytest.fixture -async def db_session() -> AsyncGenerator[AsyncSession, None]: - """Cada teste roda em uma transação que é revertida ao final.""" +async def db_session(create_tables) -> AsyncGenerator[AsyncSession, None]: + """Run each test inside a transaction that is rolled back at the end.""" + async with test_engine.connect() as conn: await conn.begin() session = AsyncSession(bind=conn, expire_on_commit=False) + try: yield session finally: @@ -131,7 +148,7 @@ async def db_session() -> AsyncGenerator[AsyncSession, None]: @pytest.fixture async def async_client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: - """Cliente HTTP assíncrono com override de sessão de banco.""" + """Async HTTP client with a database session override.""" async def override_get_db() -> AsyncGenerator[AsyncSession, None]: yield db_session diff --git a/backend/tests/unit/test_ai_client.py b/backend/tests/unit/test_ai_client.py new file mode 100644 index 0000000..0af4639 --- /dev/null +++ b/backend/tests/unit/test_ai_client.py @@ -0,0 +1,215 @@ +"""Unit tests for AI client.""" + +import pytest + +from pequi.config import Settings +from pequi.integrations.ai_client import SYSTEM_PROMPT, AIClient, get_anthropic_client + + +@pytest.fixture +def mock_settings(): + """Mock settings with test credentials.""" + return Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + ANTHROPIC_API_KEY="test_key", + ANTHROPIC_MODEL="claude-3-5-haiku-20241022", + ) + + +class TestAIClient: + """Test AI client functionality.""" + + def test_init_with_api_key(self, mock_settings): + """Test client initialization with API key.""" + client = AIClient(settings=mock_settings) + assert client.api_key == "test_key" + assert client.model == "claude-3-5-haiku-20241022" + assert client.client is not None + + def test_init_without_api_key(self, mock_settings, caplog): + """Test client initialization without API key.""" + mock_settings.ANTHROPIC_API_KEY = "" + + with caplog.at_level("WARNING"): + client = AIClient(settings=mock_settings) + + assert client.client is None + assert "Anthropic API key not configured" in caplog.text + + def test_init_with_custom_parameters(self): + """Test client initialization with custom parameters.""" + client = AIClient(api_key="custom_key", model="custom_model") + assert client.api_key == "custom_key" + assert client.model == "custom_model" + + def test_init_strips_api_key(self): + """Test that the API key is stripped of whitespace.""" + client = AIClient(api_key=" custom_key ", model="custom_model") + assert client.api_key == "custom_key" + + @pytest.mark.asyncio + async def test_generate_checkin_feedback_success(self, mock_settings, mocker): + """Test successful feedback generation.""" + mock_response = mocker.Mock() + mock_content = mocker.Mock() + mock_content.text = ( + "Entendo que você está passando por um momento difícil. Continue acompanhando " + "com sua equipe de saúde." + ) + mock_response.content = [mock_content] + + mock_client = mocker.Mock() + mock_client.messages.create = mocker.AsyncMock(return_value=mock_response) + + client = AIClient(settings=mock_settings, client=mock_client) + feedback = await client.generate_checkin_feedback( + symptoms=["dor", "fadiga"], + intensity=7, + mood="triste", + history_summary="Paciente em tratamento há 3 meses", + ) + + assert feedback == ( + "Entendo que você está passando por um momento difícil. Continue acompanhando " + "com sua equipe de saúde." + ) + mock_client.messages.create.assert_called_once() + call_args = mock_client.messages.create.call_args + assert call_args.kwargs["model"] == "claude-3-5-haiku-20241022" + assert call_args.kwargs["max_tokens"] == 500 + assert call_args.kwargs["system"] == SYSTEM_PROMPT + assert call_args.kwargs["timeout"] == 30.0 + + @pytest.mark.asyncio + async def test_generate_checkin_feedback_truncation(self, mock_settings, mocker): + """Test that feedback is truncated to 500 characters.""" + long_text = "A" * 600 + mock_response = mocker.Mock() + mock_content = mocker.Mock() + mock_content.text = long_text + mock_response.content = [mock_content] + + mock_client = mocker.Mock() + mock_client.messages.create = mocker.AsyncMock(return_value=mock_response) + + client = AIClient(settings=mock_settings, client=mock_client) + feedback = await client.generate_checkin_feedback(symptoms=["dor"], intensity=5, mood="ok") + + assert len(feedback) == 500 + assert feedback.endswith("...") + + @pytest.mark.asyncio + async def test_generate_checkin_feedback_no_client(self, mock_settings, caplog): + """Test feedback generation when client is not initialized.""" + mock_settings.ANTHROPIC_API_KEY = "" + + with caplog.at_level("WARNING"): + client = AIClient(settings=mock_settings) + feedback = await client.generate_checkin_feedback( + symptoms=["dor"], intensity=5, mood="ok" + ) + + assert feedback == "" + assert "Anthropic client not initialized" in caplog.text + + @pytest.mark.asyncio + async def test_generate_checkin_feedback_api_error(self, mock_settings, mocker, caplog): + """Test that API errors are logged and return empty string.""" + mock_client = mocker.Mock() + mock_client.messages.create = mocker.AsyncMock(side_effect=Exception("API timeout")) + + with caplog.at_level("ERROR"): + client = AIClient(settings=mock_settings, client=mock_client) + feedback = await client.generate_checkin_feedback( + symptoms=["dor"], intensity=5, mood="ok" + ) + + assert feedback == "" + assert "Failed to generate AI feedback" in caplog.text + assert "dor" not in caplog.text + assert "ok" not in caplog.text + + @pytest.mark.asyncio + async def test_generate_checkin_feedback_empty_symptoms(self, mock_settings, mocker): + """Test feedback generation with empty symptoms list.""" + mock_response = mocker.Mock() + mock_content = mocker.Mock() + mock_content.text = "Obrigado pelo seu check-in." + mock_response.content = [mock_content] + + mock_client = mocker.Mock() + mock_client.messages.create = mocker.AsyncMock(return_value=mock_response) + + client = AIClient(settings=mock_settings, client=mock_client) + feedback = await client.generate_checkin_feedback(symptoms=[], intensity=3, mood="bem") + + assert feedback == "Obrigado pelo seu check-in." + call_args = mock_client.messages.create.call_args + assert "nenhum sintoma relatado" in call_args.kwargs["messages"][0]["content"] + + @pytest.mark.asyncio + async def test_generate_checkin_feedback_with_history(self, mock_settings, mocker): + """Test feedback generation with patient history.""" + mock_response = mocker.Mock() + mock_content = mocker.Mock() + mock_content.text = "Considerando seu histórico, continue o tratamento." + mock_response.content = [mock_content] + + mock_client = mocker.Mock() + mock_client.messages.create = mocker.AsyncMock(return_value=mock_response) + + client = AIClient(settings=mock_settings, client=mock_client) + feedback = await client.generate_checkin_feedback( + symptoms=["dor"], + intensity=6, + mood="ansioso", + history_summary="Paciente com histórico de reações", + ) + + assert feedback == "Considerando seu histórico, continue o tratamento." + call_args = mock_client.messages.create.call_args + assert "Paciente com histórico de reações" in call_args.kwargs["messages"][0]["content"] + + def test_system_prompt_content(self): + """Test that system prompt has correct content.""" + assert "português brasileiro" in SYSTEM_PROMPT + assert "empática" in SYSTEM_PROMPT.lower() + assert "sem fazer diagnósticos clínicos" in SYSTEM_PROMPT.lower() + assert "500 caracteres" in SYSTEM_PROMPT + + +class TestGetAnthropicClient: + """Test factory function for AI client.""" + + def test_get_client_with_api_key(self, mock_settings, mocker): + """Test factory function with API key configured.""" + mocker.patch("pequi.integrations.ai_client.get_settings", return_value=mock_settings) + + client = get_anthropic_client() + assert client is not None + assert isinstance(client, AIClient) + + def test_get_client_without_api_key(self, mocker): + """Test factory function without API key configured.""" + mock_settings = Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + ANTHROPIC_API_KEY="", + ) + mocker.patch("pequi.integrations.ai_client.get_settings", return_value=mock_settings) + + client = get_anthropic_client() + assert client is None + + def test_get_client_with_whitespace_api_key(self, mocker): + """Test factory function with whitespace-only API key.""" + mock_settings = Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + ANTHROPIC_API_KEY=" ", + ) + mocker.patch("pequi.integrations.ai_client.get_settings", return_value=mock_settings) + + client = get_anthropic_client() + assert client is None diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py new file mode 100644 index 0000000..f097026 --- /dev/null +++ b/backend/tests/unit/test_notification_service.py @@ -0,0 +1,30 @@ +from uuid import uuid4 + +import pytest + +from pequi.services.notification_service import NotificationService + + +@pytest.mark.asyncio +async def test_send_feedback_sends_whatsapp_when_phone_is_available(mocker): + whatsapp_client = mocker.Mock() + whatsapp_client.send_message = mocker.AsyncMock(return_value="msg_123") + service = NotificationService(whatsapp_client=whatsapp_client) + + await service.send_feedback(uuid4(), "Feedback de teste", recipient_phone="+5511999999999") + + whatsapp_client.send_message.assert_awaited_once_with( + "+5511999999999", + "Feedback de teste", + ) + + +@pytest.mark.asyncio +async def test_send_feedback_skips_whatsapp_without_phone(mocker): + whatsapp_client = mocker.Mock() + whatsapp_client.send_message = mocker.AsyncMock(return_value="msg_123") + service = NotificationService(whatsapp_client=whatsapp_client) + + await service.send_feedback(uuid4(), "Feedback de teste") + + whatsapp_client.send_message.assert_not_awaited() diff --git a/backend/tests/unit/test_object_storage_client.py b/backend/tests/unit/test_object_storage_client.py new file mode 100644 index 0000000..addb73d --- /dev/null +++ b/backend/tests/unit/test_object_storage_client.py @@ -0,0 +1,205 @@ +from unittest.mock import AsyncMock + +import pytest + +from pequi.config import Settings +from pequi.integrations.object_storage import ObjectStorageClient + + +class AsyncContextManager: + def __init__(self, value): + self.value = value + + async def __aenter__(self): + return self.value + + async def __aexit__(self, exc_type, exc, tb): + return False + + +@pytest.fixture +def mock_settings(): + return Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + STORAGE_ENDPOINT="https://test-storage.local", + STORAGE_ACCESS_KEY="test_access_key", + STORAGE_SECRET_KEY="test_secret_key", + STORAGE_BUCKET_IMAGES="test-bucket", + STORAGE_REGION="us-east-1", + STORAGE_PUBLIC_URL="https://cdn.test-storage.local", + ) + + +@pytest.fixture +def storage_client(mock_settings): + return ObjectStorageClient(settings=mock_settings) + + +@pytest.mark.asyncio +async def test_get_success(storage_client, mocker): + mock_body = AsyncMock() + mock_body.read = AsyncMock(return_value=b"file-content") + mock_body.__aenter__ = AsyncMock(return_value=mock_body) + mock_body.__aexit__ = AsyncMock(return_value=False) + + mock_client = mocker.Mock() + mock_client.get_object = AsyncMock(return_value={"Body": mock_body}) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + result = await storage_client.get("files/test.txt") + + assert result == b"file-content" + mock_client.get_object.assert_called_once_with(Bucket="test-bucket", Key="files/test.txt") + + +@pytest.mark.asyncio +async def test_get_failure_logs_error(storage_client, mocker, caplog): + mock_client = mocker.Mock() + mock_client.get_object = AsyncMock(side_effect=Exception("download failed")) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + with caplog.at_level("ERROR"), pytest.raises(Exception, match="download failed"): + await storage_client.get("files/test.txt") + + assert "Failed to get object from storage" in caplog.text + + +@pytest.mark.asyncio +async def test_delete_success(storage_client, mocker): + mock_client = mocker.Mock() + mock_client.delete_object = AsyncMock(return_value=None) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + await storage_client.delete("files/test.txt") + mock_client.delete_object.assert_called_once_with(Bucket="test-bucket", Key="files/test.txt") + + +@pytest.mark.asyncio +async def test_delete_failure_logs_error_and_does_not_raise(storage_client, mocker, caplog): + mock_client = mocker.Mock() + mock_client.delete_object = AsyncMock(side_effect=Exception("delete failed")) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + with caplog.at_level("ERROR"): + await storage_client.delete("files/test.txt") + + assert "Failed to delete object from storage" in caplog.text + + +@pytest.mark.asyncio +async def test_upload_returns_public_url_when_configured(storage_client, mocker): + mock_client = mocker.Mock() + mock_client.put_object = AsyncMock(return_value=None) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + result = await storage_client.upload("files/test.txt", b"file-content", "text/plain") + + assert result == "https://cdn.test-storage.local/test-bucket/files/test.txt" + mock_client.put_object.assert_called_once_with( + Bucket="test-bucket", + Key="files/test.txt", + Body=b"file-content", + ContentType="text/plain", + ) + + +@pytest.mark.asyncio +async def test_upload_returns_presigned_url_when_public_url_is_not_configured( + mock_settings, + mocker, +): + storage_client = ObjectStorageClient(settings=mock_settings, public_url="") + mock_client = mocker.Mock() + mock_client.put_object = AsyncMock(return_value=None) + mock_client.generate_presigned_url = AsyncMock(return_value="https://signed.example/test.txt") + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + result = await storage_client.upload("files/test.txt", b"file-content") + + assert result == "https://signed.example/test.txt" + mock_client.generate_presigned_url.assert_called_once_with( + "get_object", + Params={"Bucket": "test-bucket", "Key": "files/test.txt"}, + ExpiresIn=3600, + ) + + +@pytest.mark.asyncio +async def test_generate_presigned_url_success(storage_client, mocker): + mock_client = mocker.Mock() + mock_client.generate_presigned_url = AsyncMock(return_value="https://signed.example/test.txt") + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + result = await storage_client.generate_presigned_url("files/test.txt", expires=600) + + assert result == "https://signed.example/test.txt" + mock_client.generate_presigned_url.assert_called_once_with( + "get_object", + Params={"Bucket": "test-bucket", "Key": "files/test.txt"}, + ExpiresIn=600, + ) + + +@pytest.mark.asyncio +async def test_exists_returns_true_when_object_exists(storage_client, mocker): + mock_client = mocker.Mock() + mock_client.head_object = AsyncMock(return_value={}) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + assert await storage_client.exists("files/test.txt") is True + mock_client.head_object.assert_called_once_with(Bucket="test-bucket", Key="files/test.txt") + + +@pytest.mark.asyncio +async def test_exists_returns_false_when_object_does_not_exist(storage_client, mocker): + mock_client = mocker.Mock() + mock_client.head_object = AsyncMock(side_effect=Exception("not found")) + + mocker.patch.object( + storage_client, + "_get_client", + new=AsyncMock(return_value=AsyncContextManager(mock_client)), + ) + + assert await storage_client.exists("files/test.txt") is False diff --git a/backend/tests/unit/test_whatsapp_client.py b/backend/tests/unit/test_whatsapp_client.py new file mode 100644 index 0000000..c02531e --- /dev/null +++ b/backend/tests/unit/test_whatsapp_client.py @@ -0,0 +1,261 @@ +"""Unit tests for WhatsApp client.""" + +from unittest.mock import AsyncMock + +import pytest +from httpx import AsyncClient, Response + +from pequi.config import Settings +from pequi.integrations.whatsapp import WhatsAppClient + + +@pytest.fixture +def mock_settings(): + """Mock settings with test credentials.""" + return Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + WHATSAPP_PROVIDER="twilio", + TWILIO_ACCOUNT_SID="test_sid", + TWILIO_AUTH_TOKEN="test_token", + TWILIO_WHATSAPP_FROM="whatsapp:+14155238886", + EVOLUTION_API_URL="https://test.evolution.api", + EVOLUTION_API_KEY="test_key", + EVOLUTION_INSTANCE="pequi-dev", + TWILIO_CONTENT_SIDS={"test_template": "HX123"}, + ) + + +@pytest.fixture +def mock_http_client(): + """Mock HTTP client for testing.""" + return AsyncClient() + + +@pytest.fixture +def whatsapp_client(mock_http_client, mock_settings): + """Create WhatsApp client with mock HTTP client and settings.""" + return WhatsAppClient(provider="twilio", http_client=mock_http_client, settings=mock_settings) + + +class TestWhatsAppClient: + """Test WhatsApp client functionality.""" + + def test_init_default_provider(self, mock_http_client, mock_settings): + """Test client initialization with default provider.""" + client = WhatsAppClient(http_client=mock_http_client, settings=mock_settings) + assert client.provider == "twilio" + + def test_init_custom_provider(self, mock_http_client, mock_settings): + """Test client initialization with custom provider.""" + client = WhatsAppClient( + provider="evolution", + http_client=mock_http_client, + settings=mock_settings, + ) + assert client.provider == "evolution" + + @pytest.mark.asyncio + async def test_send_message_twilio_success(self, whatsapp_client, mocker): + """Test sending message via Twilio successfully.""" + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"sid": "test_message_id"} + + mocker.patch.object( + whatsapp_client.http_client, + "post", + new=AsyncMock(return_value=mock_response), + ) + + message_id = await whatsapp_client.send_message("+5511999999999", "Test message") + assert message_id == "test_message_id" + + @pytest.mark.asyncio + async def test_send_message_twilio_failure_logs_warning(self, whatsapp_client, mocker, caplog): + """Test that Twilio failure logs warning and returns empty string.""" + mocker.patch.object( + whatsapp_client.http_client, + "post", + new=AsyncMock(side_effect=Exception("API error")), + ) + + with caplog.at_level("WARNING"): + message_id = await whatsapp_client.send_message("+5511999999999", "Test message") + + assert message_id == "" + assert "Failed to send WhatsApp message" in caplog.text + + @pytest.mark.asyncio + async def test_send_message_evolution_success(self, mock_settings, mocker): + """Test sending message via Evolution API successfully.""" + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"key": {"id": "test_message_id"}} + + mock_http_client = mocker.Mock(spec=AsyncClient) + mock_http_client.post = AsyncMock(return_value=mock_response) + + client = WhatsAppClient( + provider="evolution", + http_client=mock_http_client, + settings=mock_settings, + ) + message_id = await client.send_message("+5511999999999", "Test message") + assert message_id == "test_message_id" + mock_http_client.post.assert_called_once() + call_args = mock_http_client.post.call_args + assert call_args.args[0] == "https://test.evolution.api/message/sendText/pequi-dev" + assert call_args.kwargs["headers"] == { + "Content-Type": "application/json", + "apikey": "test_key", + } + + @pytest.mark.asyncio + async def test_send_template_twilio_success(self, whatsapp_client, mocker): + """Test sending template via Twilio successfully.""" + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"sid": "test_template_id"} + + mocker.patch.object( + whatsapp_client.http_client, + "post", + new=AsyncMock(return_value=mock_response), + ) + + template_id = await whatsapp_client.send_template( + "+5511999999999", "test_template", {"param1": "value1"} + ) + assert template_id == "test_template_id" + data = whatsapp_client.http_client.post.call_args.kwargs["data"] + assert data["ContentSid"] == "HX123" + assert data["ContentVariables"] == '{"param1":"value1"}' + assert "MessagingServiceSid" not in data + + @pytest.mark.asyncio + async def test_send_template_evolution_success(self, mock_settings, mocker): + """Test sending template via Evolution API successfully.""" + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"key": {"id": "test_template_id"}} + + mock_http_client = mocker.Mock(spec=AsyncClient) + mock_http_client.post = AsyncMock(return_value=mock_response) + + client = WhatsAppClient( + provider="evolution", + http_client=mock_http_client, + settings=mock_settings, + ) + template_id = await client.send_template( + "+5511999999999", "test_template", {"param1": "value1"} + ) + assert template_id == "test_template_id" + mock_http_client.post.assert_called_once() + call_args = mock_http_client.post.call_args + assert call_args.args[0] == "https://test.evolution.api/message/sendTemplate/pequi-dev" + assert call_args.kwargs["headers"] == { + "Content-Type": "application/json", + "apikey": "test_key", + } + assert call_args.kwargs["json"] == { + "number": "+5511999999999", + "name": "test_template", + "language": {"code": "pt_BR"}, + "components": [ + { + "type": "body", + "parameters": [{"type": "text", "text": "value1"}], + } + ], + } + + @pytest.mark.asyncio + async def test_send_message_retry_with_backoff(self, whatsapp_client, mocker): + """Test that message sending retries with exponential backoff.""" + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"sid": "test_message_id"} + + post_mock = mocker.patch.object( + whatsapp_client.http_client, + "post", + new=AsyncMock(side_effect=[Exception("error"), Exception("error"), mock_response]), + ) + + message_id = await whatsapp_client.send_message("+5511999999999", "Test message") + assert message_id == "test_message_id" + assert post_mock.call_count == 3 + + @pytest.mark.asyncio + async def test_send_message_unsupported_provider(self, mock_http_client, mock_settings): + """Test that unsupported provider raises ValueError.""" + client = WhatsAppClient( + provider="unsupported", + http_client=mock_http_client, + settings=mock_settings, + ) + with pytest.raises(ValueError, match="Unsupported provider"): + await client.send_message("+5511999999999", "Test message") + + @pytest.mark.asyncio + async def test_send_message_twilio_no_credentials(self, mock_http_client): + """Test that missing Twilio credentials raises ValueError.""" + mock_settings = Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + WHATSAPP_PROVIDER="twilio", + TWILIO_ACCOUNT_SID="", + TWILIO_AUTH_TOKEN="", + TWILIO_WHATSAPP_FROM="whatsapp:+14155238886", + ) + client = WhatsAppClient( + provider="twilio", + http_client=mock_http_client, + settings=mock_settings, + ) + + with pytest.raises(ValueError, match="Twilio credentials not configured"): + await client._send_twilio_message("+5511999999999", "Test") + + @pytest.mark.asyncio + async def test_send_message_evolution_no_credentials(self, mock_http_client): + """Test that missing Evolution credentials raises ValueError.""" + mock_settings = Settings( + SECRET_KEY="test-secret", + DATABASE_URL="postgresql+asyncpg://test:test@localhost/test", + WHATSAPP_PROVIDER="evolution", + EVOLUTION_API_URL="", + EVOLUTION_API_KEY="", + ) + client = WhatsAppClient( + provider="evolution", + http_client=mock_http_client, + settings=mock_settings, + ) + + with pytest.raises(ValueError, match="Evolution API credentials not configured"): + await client._send_evolution_message("+5511999999999", "Test") + + @pytest.mark.asyncio + async def test_close_http_client(self, mock_settings, mocker): + """Test that owned HTTP client is closed properly.""" + client = WhatsAppClient(settings=mock_settings) + mocker.patch.object(client.http_client, "aclose", new=AsyncMock()) + + await client.close() + + client.http_client.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_send_message_invalid_phone_format(self, whatsapp_client): + """Test that invalid phone numbers raise ValueError.""" + with pytest.raises(ValueError, match=r"E\.164 format"): + await whatsapp_client.send_message("5551999999999", "Test message") + + @pytest.mark.asyncio + async def test_send_template_invalid_phone_format(self, whatsapp_client): + """Test that invalid phone numbers for templates raise ValueError.""" + with pytest.raises(ValueError, match=r"E\.164 format"): + await whatsapp_client.send_template("5511999999999", "template", {"param1": "value1"}) From 5506640ec68fcf4a29d22b29f1db5727b58af0ba Mon Sep 17 00:00:00 2001 From: Sarah Domingos <92494941+sarahdomingos@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:35:41 -0300 Subject: [PATCH 46/69] Feat/medications and cards 32 35 (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: rota para medicamentos a partir da home * fix: ajuste na lógica de cadastro de medicamentos * feat: marcação de medicamentos finalizado * test: adição de testes para medicamentos * feat: cards com mock na home * fix: correções do comentário do PR aplicadas --- frontend/src/app/app.routes.ts | 2 + frontend/src/app/features/home/home.css | 89 +++++ frontend/src/app/features/home/home.html | 15 + frontend/src/app/features/home/home.ts | 31 +- .../app/features/medication/medication.css | 196 ++++++++++ .../app/features/medication/medication.html | 292 +++++++++++++++ .../features/medication/medication.spec.ts | 180 ++++++++++ .../src/app/features/medication/medication.ts | 337 ++++++++++++++++++ .../services/medication-data.service.spec.ts | 46 +++ .../services/medication-data.service.ts | 61 ++++ frontend/src/app/models/medication-model.ts | 13 + 11 files changed, 1261 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/features/medication/medication.css create mode 100644 frontend/src/app/features/medication/medication.html create mode 100644 frontend/src/app/features/medication/medication.spec.ts create mode 100644 frontend/src/app/features/medication/medication.ts create mode 100644 frontend/src/app/features/medication/services/medication-data.service.spec.ts create mode 100644 frontend/src/app/features/medication/services/medication-data.service.ts create mode 100644 frontend/src/app/models/medication-model.ts diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 1fed051..328daa8 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,6 +15,7 @@ import { Login } from './features/login/login'; import { Register } from './features/register/register'; import { Onboarding } from './features/onboarding/onboarding'; import { authGuard } from './features/auth/guards/auth-guard'; +import { Medication } from './features/medication/medication'; export const routes: Routes = [ { path: '', pathMatch: 'full', component: Onboarding, title: 'Bem-vindo' }, @@ -29,6 +30,7 @@ export const routes: Routes = [ { path: 'home', component: HomeComponent, title: 'Início' }, { path: 'journey', component: Journey, title: 'Jornada' }, { path: 'checkin', component: CheckinComponent, title: 'Check In' }, + { path: 'medication', component: Medication, title: 'Remédios' }, { path: 'appointments/register', component: RegisterAppointmentComponent, diff --git a/frontend/src/app/features/home/home.css b/frontend/src/app/features/home/home.css index bd16ed2..904e68d 100644 --- a/frontend/src/app/features/home/home.css +++ b/frontend/src/app/features/home/home.css @@ -151,6 +151,11 @@ color: #5b48d9; } +.yellow-icon { + background-color: #f5f6e7; + color: #d9c148; +} + .card-text { display: flex; flex-direction: column; @@ -248,3 +253,87 @@ background-color: #fafafa; } +.summary-section { + margin-top: 20px; + margin-bottom: 28px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.summary-card { + min-height: 172px; + border-radius: 36px; + padding: 28px 24px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.summary-card--purple { + background: #dfd8f0; +} + +.summary-card--blue { + background: #d9eaf0; +} + +.summary-card__value { + font-size: 28px; + line-height: 1.1; + font-weight: 800; + color: #3f5f73; + margin-bottom: 10px; +} + +.summary-card--purple .summary-card__value { + color: #5b4fb3; +} + +.summary-card__title { + font-size: 22px; + line-height: 1.25; + font-weight: 500; + color: #3f5f73; + max-width: 10ch; +} + +.summary-card--purple .summary-card__title { + color: #3f3b8f; +} + +.summary-card__subtitle { + margin-top: 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 400; + color: #33444c; +} + +@media (max-width: 768px) { + .summary-grid { + grid-template-columns: 1fr; + } + + .summary-card { + min-height: 148px; + border-radius: 28px; + padding: 24px 20px; + } + + .summary-card__value { + font-size: 24px; + } + + .summary-card__title { + font-size: 17px; + } + + .summary-card__subtitle { + font-size: 15px; + } +} + diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index c39dec9..b09548c 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -26,6 +26,21 @@

{{ currentMonthYear }}

}
+ +
+
+ @for (card of summaryCards; track $index) { +
+ {{ card.value }} + {{ card.title }} + @if (card.subtitle) { + {{ card.subtitle }} + } +
+ } +
+
+

Ações rápidas

diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index 76a1bb6..4ba3e03 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -1,6 +1,6 @@ import { Component, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope } from 'lucide-angular'; +import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope, Pill } from 'lucide-angular'; import { Router, RouterLink } from '@angular/router'; interface QuickAction { @@ -27,6 +27,13 @@ interface Article { actionUrl: string; } +interface HomeHighlightCard { + value: string; + title: string; + subtitle?: string; + backgroundClass: string; +} + @Component({ selector: 'app-home', standalone: true, @@ -40,11 +47,26 @@ export class HomeComponent implements OnInit { readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; readonly Stethoscope = Stethoscope; + readonly Pill = Pill; currentMonthYear: string = ''; calendarWeek: CalendarWeek[] = []; selectedDate: Date = new Date(); + summaryCards: HomeHighlightCard[] = [ + { + value: '2/4', + title: 'Medicações tomadas', + backgroundClass: 'summary-card--purple', + }, + { + value: '27/06/2026', + title: '15:30', + subtitle: 'Próxima consulta', + backgroundClass: 'summary-card--blue', + }, + ]; + QuickAction = [ { title: 'Check-in', @@ -52,6 +74,13 @@ export class HomeComponent implements OnInit { icon: this.CirclePlus, colorClass: 'blue-icon', path: '/checkin', + }, + { + title: 'Registrar medicamentos', + description: 'Veja quais remédios tomar hoje', + icon: this.Pill, + colorClass: 'yellow-icon', + path: '/medication', }, { title: 'Registrar consulta', diff --git a/frontend/src/app/features/medication/medication.css b/frontend/src/app/features/medication/medication.css new file mode 100644 index 0000000..281ad71 --- /dev/null +++ b/frontend/src/app/features/medication/medication.css @@ -0,0 +1,196 @@ +:host { + display: block; +} + +.medication { + width: 100%; + padding: 8px 0; + background: #f7f5f1; +} + +.medication__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; +} + +.medication__title-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.medication__title-icon { + width: 18px; + height: 18px; + border: 1.5px solid #7c7a74; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #7c7a74; + font-size: 14px; + line-height: 1; + font-weight: 700; +} + +.medication__title { + margin: 0; + font-size: 16px; + font-weight: 700; + line-height: 1.2; + color: #26241f; +} + +.medication__section + .medication__section { + margin-top: 18px; +} + +.medication__section-title { + margin: 0 0 10px; + font-size: 13px; + font-weight: 700; + color: #8e8a84; +} + +.medication__list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.medication-card { + min-height: 108px; + border-radius: 30px; + background: #f3f1ed; + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 24px; +} + +.medication-card--checked { + opacity: 0.58; +} + +.medication-card__content { + min-width: 0; + display: flex; + align-items: center; + gap: 18px; +} + +.medication-card__icon { + width: 56px; + height: 56px; + border-radius: 999px; + background: #f5f6e7; + color: #d9c148; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + flex-shrink: 0; +} + +.medication-card__icon--checked { + background: #ededea; + color: #8da0aa; +} + +.medication-card__text { + min-width: 0; +} + +.medication-card__title-text { + margin: 0; + font-size: 18px; + line-height: 1.2; + font-weight: 700; + color: #27251f; +} + +.medication-card__subtitle { + margin: 4px 0 0; + font-size: 16px; + line-height: 1.2; + color: #78756f; +} + +.medication-card__action { + margin-left: 16px; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.medication-switch { + position: relative; + width: 42px; + height: 24px; + display: inline-flex; +} + +.medication-switch input { + position: absolute; + opacity: 0; + inset: 0; +} + +.medication-switch__slider { + position: absolute; + inset: 0; + border-radius: 999px; + background: #dfddd5; + transition: 180ms ease; +} + +.medication-switch__slider::before { + content: ''; + position: absolute; + left: 3px; + top: 3px; + width: 18px; + height: 18px; + border-radius: 999px; + background: #f8f7f3; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + transition: 180ms ease; +} + +.medication-switch input:checked + .medication-switch__slider { + background: #cfe3ee; +} + +.medication-switch input:checked + .medication-switch__slider::before { + transform: translateX(18px); +} + +@media (max-width: 640px) { + .medication-card { + min-height: 96px; + padding: 18px 18px; + border-radius: 24px; + } + + .medication-card__icon { + width: 48px; + height: 48px; + font-size: 20px; + } + + .medication-card__title-text { + font-size: 16px; + } + + .medication-card__subtitle { + font-size: 14px; + } + + .medication-card__badge { + min-width: 64px; + height: 30px; + font-size: 13px; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/medication/medication.html b/frontend/src/app/features/medication/medication.html new file mode 100644 index 0000000..d4d24a3 --- /dev/null +++ b/frontend/src/app/features/medication/medication.html @@ -0,0 +1,292 @@ +
+
+

Remédios

+

+ Registre as medicações que você já tomou hoje +

+
+ +

+ Dose não supervisionada: remédios para tomar em casa +

+ +
+
+
+
+
+ +
+ +
+

+ {{ item.title }} +

+ +

+ {{ item.subtitle }} +

+
+
+ +
+ + + +
+
+
+
+ +
+

Nenhum medicamento de dose não supervisionada cadastrado.

+
+ +

+ Dose supervisionada: remédios para tomar na Unidade de Saúde responsável +

+ +
+
+
+
+
+ +
+ +
+

+ {{ item.title }} +

+ +

+ {{ item.subtitle }} +

+
+
+ +
+ + + +
+
+
+
+ +
+

Nenhum medicamento de dose supervisionada cadastrado.

+
+
+ +
+ + \ No newline at end of file diff --git a/frontend/src/app/features/medication/medication.spec.ts b/frontend/src/app/features/medication/medication.spec.ts new file mode 100644 index 0000000..7c3a19a --- /dev/null +++ b/frontend/src/app/features/medication/medication.spec.ts @@ -0,0 +1,180 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi, type Mocked } from 'vitest'; + +import { Medication } from './medication'; +import { + MedicationDataService, + type MedicationChecklistResponse, +} from './services/medication-data.service'; + +describe('Medication', () => { + let component: Medication; + let fixture: ComponentFixture; + let medicationDataServiceSpy: Mocked; + + const mockResponse: MedicationChecklistResponse = { + institutedMedications: [ + { + name: 'Suplemento Noturno', + dose: '500', + unit: 'mg', + frequency: '08:00 PM', + }, + { + name: 'Vitamina Matinal', + dose: '1', + unit: 'Unidade', + frequency: '08:00 AM', + }, + ], + currentDoseMedication: 'Rifampicina + Clofazimina', + }; + + beforeEach(async () => { + medicationDataServiceSpy = { + getMedicationChecklist: vi.fn().mockReturnValue(of(mockResponse)), + } as Mocked; + + await TestBed.configureTestingModule({ + imports: [Medication], + providers: [ + { provide: MedicationDataService, useValue: medicationDataServiceSpy }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(Medication); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should load medication checklist on init', () => { + fixture.detectChanges(); + + expect(medicationDataServiceSpy.getMedicationChecklist).toHaveBeenCalled(); + expect(component.unsupervisedItems.length).toBe(2); + expect(component.supervisedItems.length).toBe(1); + }); + + it('should map unsupervised medications correctly', () => { + fixture.detectChanges(); + + expect(component.unsupervisedItems[0].title).toBe('Suplemento Noturno'); + expect(component.unsupervisedItems[0].subtitle).toBe('500 • mg • 08:00 PM'); + + expect(component.unsupervisedItems[1].title).toBe('Vitamina Matinal'); + expect(component.unsupervisedItems[1].subtitle).toBe('1 • Unidade • 08:00 AM'); + }); + + it('should map supervised medication correctly', () => { + fixture.detectChanges(); + + expect(component.supervisedItems[0].title).toBe('Rifampicina + Clofazimina'); + expect(component.supervisedItems[0].subtitle).toBe(''); + expect(component.supervisedItems[0].checked).toBeFalsy(); + }); + + it('should toggle unsupervised item', () => { + fixture.detectChanges(); + + const itemId = component.unsupervisedItems[0].id; + + component.toggleUnsupervised(itemId); + expect(component.unsupervisedItems[0].checked).toBeTruthy(); + + component.toggleUnsupervised(itemId); + expect(component.unsupervisedItems[0].checked).toBeFalsy(); + }); + + it('should toggle supervised item', () => { + fixture.detectChanges(); + + const itemId = component.supervisedItems[0].id; + + component.toggleSupervised(itemId); + expect(component.supervisedItems[0].checked).toBeTruthy(); + + component.toggleSupervised(itemId); + expect(component.supervisedItems[0].checked).toBeFalsy(); + }); + + it('should emit checklist payload after loading data', () => { + const emitSpy = vi.spyOn(component.checklistChange, 'emit'); + + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith({ + checkedCount: 0, + totalCount: 3, + unsupervisedCheckedCount: 0, + unsupervisedTotalCount: 2, + supervisedCheckedCount: 0, + supervisedTotalCount: 1, + }); + }); + + it('should emit updated payload when toggling unsupervised item', () => { + fixture.detectChanges(); + const emitSpy = vi.spyOn(component.checklistChange, 'emit'); + + const itemId = component.unsupervisedItems[0].id; + component.toggleUnsupervised(itemId); + + expect(emitSpy).toHaveBeenCalledWith({ + checkedCount: 1, + totalCount: 3, + unsupervisedCheckedCount: 1, + unsupervisedTotalCount: 2, + supervisedCheckedCount: 0, + supervisedTotalCount: 1, + }); + }); + + it('should emit updated payload when toggling supervised item', () => { + fixture.detectChanges(); + const emitSpy = vi.spyOn(component.checklistChange, 'emit'); + + const itemId = component.supervisedItems[0].id; + component.toggleSupervised(itemId); + + expect(emitSpy).toHaveBeenCalledWith({ + checkedCount: 1, + totalCount: 3, + unsupervisedCheckedCount: 0, + unsupervisedTotalCount: 2, + supervisedCheckedCount: 1, + supervisedTotalCount: 1, + }); + }); + + it('should clear lists when service returns error', async () => { + medicationDataServiceSpy.getMedicationChecklist.mockReturnValue( + throwError(() => new Error('erro')) + ); + + fixture = TestBed.createComponent(Medication); + component = fixture.componentInstance; + + fixture.detectChanges(); + + expect(component.unsupervisedItems.length).toBe(0); + expect(component.supervisedItems.length).toBe(0); + expect(component.isLoading).toBeFalsy(); + }); + + it('should return item id in trackById', () => { + const item = { + id: 'abc123', + title: 'Teste', + subtitle: 'Sub', + checked: false, + section: 'unsupervised' as const, + }; + + expect(component.trackById(0, item)).toBe('abc123'); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/medication/medication.ts b/frontend/src/app/features/medication/medication.ts new file mode 100644 index 0000000..8c75c01 --- /dev/null +++ b/frontend/src/app/features/medication/medication.ts @@ -0,0 +1,337 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, EventEmitter, OnInit, Output, inject } from '@angular/core'; +import type { PatientTreatmentData } from '../profile/models/patient-profile.models'; +import { + MedicationDataService, + type MedicationChecklistResponse, + type MedicationAlarmPayload, + type MedicationAlarmConfig, +} from './services/medication-data.service'; +import { + Hospital, + Pill, + Clock3, + Bell, + X, + LucideAngularModule, +} from 'lucide-angular'; + +type InstitutedMedicationItem = PatientTreatmentData['institutedMedications'][number]; +type MedicationSection = 'unsupervised' | 'supervised'; +type WeekdayKey = + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday'; + +export interface MedicationChecklistPayload { + checkedCount: number; + totalCount: number; + unsupervisedCheckedCount: number; + unsupervisedTotalCount: number; + supervisedCheckedCount: number; + supervisedTotalCount: number; +} + +interface WeekdayOption { + key: WeekdayKey; + label: string; + shortLabel: string; +} + +interface MedicationCardItem { + id: string; + title: string; + subtitle: string; + checked: boolean; + section: MedicationSection; + doseLabel: string; + alarmEnabled: boolean; + alarmConfig: MedicationAlarmConfig; +} + +@Component({ + selector: 'app-medication', + standalone: true, + imports: [CommonModule, LucideAngularModule, FormsModule], + templateUrl: './medication.html', + styleUrl: './medication.css', +}) +export class Medication implements OnInit { + private readonly medicationDataService = inject(MedicationDataService); + + readonly Pill = Pill; + readonly Hospital = Hospital; + readonly Clock3 = Clock3; + readonly Bell = Bell; + readonly X = X; + + @Output() checklistChange = new EventEmitter(); + + isLoading = false; + + unsupervisedItems: MedicationCardItem[] = []; + supervisedItems: MedicationCardItem[] = []; + + isAlarmModalOpen = false; + selectedMedicationId: string | null = null; + selectedMedicationSection: MedicationSection | null = null; + + modalDraftDays: WeekdayKey[] = []; + modalDraftTime = '08:00'; + + readonly weekdays: WeekdayOption[] = [ + { key: 'monday', label: 'Segunda-feira', shortLabel: 'Seg' }, + { key: 'tuesday', label: 'Terça-feira', shortLabel: 'Ter' }, + { key: 'wednesday', label: 'Quarta-feira', shortLabel: 'Qua' }, + { key: 'thursday', label: 'Quinta-feira', shortLabel: 'Qui' }, + { key: 'friday', label: 'Sexta-feira', shortLabel: 'Sex' }, + { key: 'saturday', label: 'Sábado', shortLabel: 'Sáb' }, + { key: 'sunday', label: 'Domingo', shortLabel: 'Dom' }, + ]; + + ngOnInit(): void { + this.loadMedicationChecklist(); + } + + loadMedicationChecklist(): void { + this.isLoading = true; + + this.medicationDataService.getMedicationChecklist().subscribe({ + next: (response: MedicationChecklistResponse) => { + this.unsupervisedItems = this.mapUnsupervisedItems(response.institutedMedications); + this.supervisedItems = this.mapSupervisedItem(response.currentDoseMedication); + this.emitChecklistPayload(); + }, + error: () => { + this.unsupervisedItems = []; + this.supervisedItems = []; + this.emitChecklistPayload(); + this.isLoading = false; + }, + complete: () => { + this.isLoading = false; + }, + }); + } + + toggleUnsupervised(id: string): void { + this.unsupervisedItems = this.unsupervisedItems.map(item => + item.id === id ? { ...item, checked: !item.checked } : item + ); + + this.emitChecklistPayload(); + } + + toggleSupervised(id: string): void { + this.supervisedItems = this.supervisedItems.map(item => + item.id === id ? { ...item, checked: !item.checked } : item + ); + + this.emitChecklistPayload(); + } + + openAlarmModal(item: MedicationCardItem): void { + this.isAlarmModalOpen = true; + this.selectedMedicationId = item.id; + this.selectedMedicationSection = item.section; + this.modalDraftDays = [...item.alarmConfig.days]; + this.modalDraftTime = item.alarmConfig.time; + } + + closeAlarmModal(): void { + this.isAlarmModalOpen = false; + this.selectedMedicationId = null; + this.selectedMedicationSection = null; + this.modalDraftDays = []; + this.modalDraftTime = '08:00'; + } + + toggleModalDay(day: WeekdayKey): void { + const alreadySelected = this.modalDraftDays.includes(day); + + this.modalDraftDays = alreadySelected + ? this.modalDraftDays.filter(selectedDay => selectedDay !== day) + : [...this.modalDraftDays, day]; + } + + saveAlarmConfig(): void { + const item = this.getSelectedMedicationItem(); + + if (!item) { + return; + } + + const normalizedDays = this.modalDraftDays.length + ? [...this.modalDraftDays] + : this.weekdays.map(day => day.key); + + const updatedItem: MedicationCardItem = { + ...item, + alarmEnabled: true, + alarmConfig: { + days: normalizedDays, + time: this.modalDraftTime || '08:00', + }, + }; + + this.updateMedicationItem(updatedItem); + + const payload: MedicationAlarmPayload = { + medicationName: updatedItem.title, + dosage: updatedItem.doseLabel, + message: `Está na hora de tomar o remédio ${updatedItem.title}.`, + schedule: { + days: updatedItem.alarmConfig.days, + time: updatedItem.alarmConfig.time, + }, + }; + + this.medicationDataService.saveMedicationAlarm(payload).subscribe(); + this.closeAlarmModal(); + } + + getSelectedMedicationName(): string { + return this.getSelectedMedicationItem()?.title ?? ''; + } + + isDaySelected(day: WeekdayKey): boolean { + return this.modalDraftDays.includes(day); + } + + trackById(_: number, item: MedicationCardItem): string { + return item.id; + } + + private getSelectedMedicationItem(): MedicationCardItem | null { + if (!this.selectedMedicationId || !this.selectedMedicationSection) { + return null; + } + + const source = + this.selectedMedicationSection === 'unsupervised' + ? this.unsupervisedItems + : this.supervisedItems; + + return source.find(item => item.id === this.selectedMedicationId) ?? null; + } + + private updateMedicationItem(updatedItem: MedicationCardItem): void { + if (updatedItem.section === 'unsupervised') { + this.unsupervisedItems = this.unsupervisedItems.map(item => + item.id === updatedItem.id ? updatedItem : item + ); + return; + } + + this.supervisedItems = this.supervisedItems.map(item => + item.id === updatedItem.id ? updatedItem : item + ); + } + + private mapUnsupervisedItems( + items: PatientTreatmentData['institutedMedications'] + ): MedicationCardItem[] { + return items.map((item: InstitutedMedicationItem) => { + const doseLabel = this.buildDoseLabel(item.dose, item.unit); + + return { + id: crypto.randomUUID(), + title: item.name, + subtitle: this.buildSubtitle(item.dose, item.unit, item.frequency), + checked: false, + section: 'unsupervised', + doseLabel, + alarmEnabled: true, + alarmConfig: this.buildDefaultAlarmConfig(item.frequency), + }; + }); + } + + private mapSupervisedItem( + value: PatientTreatmentData['currentDoseMedication'] + ): MedicationCardItem[] { + const trimmed = value.trim(); + + if (!trimmed) return []; + + return [ + { + id: crypto.randomUUID(), + title: trimmed, + subtitle: '', + checked: false, + section: 'supervised', + doseLabel: 'Dose supervisionada', + alarmEnabled: true, + alarmConfig: this.buildDefaultAlarmConfig(), + }, + ]; + } + + private buildSubtitle(dose: string, unit: string, frequency: string): string { + const parts = [dose?.trim(), unit?.trim(), frequency?.trim()].filter(Boolean); + return parts.join(' • '); + } + + private buildDoseLabel(dose: string, unit: string): string { + const parts = [dose?.trim(), unit?.trim()].filter(Boolean); + return parts.join(' '); + } + + private buildDefaultAlarmConfig(frequency?: string): MedicationAlarmConfig { + return { + days: this.weekdays.map(day => day.key), + time: this.extractTimeFromFrequency(frequency), + }; + } + + private extractTimeFromFrequency(frequency?: string): string { + const value = frequency?.trim(); + + if (!value) { + return '08:00'; + } + + const match = value.match(/(\d{1,2}):(\d{2})\s?(AM|PM)/i); + + if (!match) { + return '08:00'; + } + + const [, hourRaw, minute, periodRaw] = match; + const period = periodRaw.toUpperCase(); + let hour = Number(hourRaw); + + if (period === 'AM' && hour === 12) { + hour = 0; + } + + if (period === 'PM' && hour < 12) { + hour += 12; + } + + return `${String(hour).padStart(2, '0')}:${minute}`; + } + + private emitChecklistPayload(): void { + const unsupervisedCheckedCount = this.unsupervisedItems.filter(item => item.checked).length; + const unsupervisedTotalCount = this.unsupervisedItems.length; + + const supervisedCheckedCount = this.supervisedItems.filter(item => item.checked).length; + const supervisedTotalCount = this.supervisedItems.length; + + this.checklistChange.emit({ + checkedCount: unsupervisedCheckedCount + supervisedCheckedCount, + totalCount: unsupervisedTotalCount + supervisedTotalCount, + unsupervisedCheckedCount, + unsupervisedTotalCount, + supervisedCheckedCount, + supervisedTotalCount, + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/medication/services/medication-data.service.spec.ts b/frontend/src/app/features/medication/services/medication-data.service.spec.ts new file mode 100644 index 0000000..57ef9e2 --- /dev/null +++ b/frontend/src/app/features/medication/services/medication-data.service.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; + +import { + MedicationDataService, + type MedicationChecklistResponse, +} from './medication-data.service'; + +describe('MedicationDataService', () => { + let service: MedicationDataService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(MedicationDataService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return medication checklist mock data', async () => { + const response: MedicationChecklistResponse = + await firstValueFrom(service.getMedicationChecklist()); + + expect(response).toBeTruthy(); + expect(response.currentDoseMedication).toBe( + 'Rifampicina + Clofazimina' + ); + + expect(response.institutedMedications.length).toBe(2); + + expect(response.institutedMedications[0]).toEqual({ + name: 'Suplemento Noturno', + dose: '500', + unit: 'mg', + frequency: '08:00 PM', + }); + + expect(response.institutedMedications[1]).toEqual({ + name: 'Vitamina Matinal', + dose: '1', + unit: 'Unidade', + frequency: '08:00 AM', + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/medication/services/medication-data.service.ts b/frontend/src/app/features/medication/services/medication-data.service.ts new file mode 100644 index 0000000..2575f0b --- /dev/null +++ b/frontend/src/app/features/medication/services/medication-data.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import type { PatientTreatmentData } from '../../profile/models/patient-profile.models'; + +export type WeekdayKey = + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday'; + +export interface MedicationAlarmConfig { + days: WeekdayKey[]; + time: string; +} + +export interface MedicationAlarmPayload { + medicationName: string; + dosage: string; + message: string; + schedule: MedicationAlarmConfig; +} + +export interface MedicationChecklistResponse { + institutedMedications: PatientTreatmentData['institutedMedications']; + currentDoseMedication: PatientTreatmentData['currentDoseMedication']; +} + +@Injectable({ + providedIn: 'root', +}) +export class MedicationDataService { + getMedicationChecklist(): Observable { + const mockResponse: MedicationChecklistResponse = { + institutedMedications: [ + { + name: 'Suplemento Noturno', + dose: '500', + unit: 'mg', + frequency: '08:00 PM', + }, + { + name: 'Vitamina Matinal', + dose: '1', + unit: 'Unidade', + frequency: '08:00 AM', + }, + ], + currentDoseMedication: 'Rifampicina + Clofazimina', + }; + + return of(mockResponse); + } + + saveMedicationAlarm(payload: MedicationAlarmPayload): Observable { + console.log('Payload de alarme da medicação:', payload); + return of(payload); + } +} \ No newline at end of file diff --git a/frontend/src/app/models/medication-model.ts b/frontend/src/app/models/medication-model.ts new file mode 100644 index 0000000..e8344db --- /dev/null +++ b/frontend/src/app/models/medication-model.ts @@ -0,0 +1,13 @@ +export type DosageUnit = 'unit' | 'mg' | 'ml'; +export type FrequencyType = 'daily' | 'weekly' | 'specific-days'; + +export interface MedicationModel { + id: string; + name: string; + dosageValue: number | null; + dosageUnit: DosageUnit; + frequencyType: FrequencyType; + weekDays?: number[]; // 0=Dom, 1=Seg ... 6=Sab + time?: string | null; // HH:mm + checked?: boolean; +} \ No newline at end of file From 7bb7609797d24a95c361b31f996c12de9c2700f8 Mon Sep 17 00:00:00 2001 From: Lucas Heron <111458155+LukeHer0@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:02:59 -0300 Subject: [PATCH 47/69] =?UTF-8?q?[PEQ-136-137]:=20Calend=C3=A1rio=20mensal?= =?UTF-8?q?=20e=20ajuste=20de=20rolagem=20no=20calend=C3=A1rio=20semanal?= =?UTF-8?q?=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add month calendar in home page * fix: center the actual day --- frontend/package-lock.json | 9 +- frontend/package.json | 3 +- frontend/src/app/features/home/home.css | 12 ++- frontend/src/app/features/home/home.html | 100 +++++++++++++++++----- frontend/src/app/features/home/home.ts | 102 ++++++++++++++++++++--- 5 files changed, 191 insertions(+), 35 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13227e5..8fd41cc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,8 @@ "@lucide/angular": "^1.16.0", "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zone.js": "^0.16.2" }, "devDependencies": { "@angular/build": "^21.2.10", @@ -9307,6 +9308,12 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zone.js": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz", + "integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==", + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 507e193..f32a084 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "@lucide/angular": "^1.16.0", "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zone.js": "^0.16.2" }, "devDependencies": { "@angular/build": "^21.2.10", diff --git a/frontend/src/app/features/home/home.css b/frontend/src/app/features/home/home.css index 904e68d..1016c36 100644 --- a/frontend/src/app/features/home/home.css +++ b/frontend/src/app/features/home/home.css @@ -32,7 +32,7 @@ .days-row { display: flex; - gap: 12px; + gap: 10px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: none; @@ -42,7 +42,9 @@ } .day-card { - min-width: 60px; + flex: 1 0 auto; + min-width: 48px; + max-width: 80px; background: #fff; border-radius: 16px; padding: 12px 8px; @@ -61,6 +63,12 @@ box-shadow: 0 4px 12px rgba(91, 72, 217, 0.3); } +.month-day-card.active { + background-color: #5b48d9; + color: #fff; + box-shadow: 0 4px 12px rgba(91, 72, 217, 0.3); +} + .day-name { font-size: 0.75rem; font-weight: 600; diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index b09548c..291877a 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -1,30 +1,92 @@
-
-

{{ currentMonthYear }}

- + +
+

{{ currentMonthYear }}

+ +
+ + +
+ } @else { +
+

{{ currentMonthYear }}

+ +
+ } + +
-
- @for (day of calendarWeek; track day.dayNumber) { -
- {{ day.dayName }} - {{ day.dayNumber }} + @if (!isExpanded()) { +
+ @for (day of calendarWeek; track day.dayNumber) { +
+ {{ day.dayName }} + {{ day.dayNumber }} +
+ @for (dot of day.dots; track $index) { +
+ } +
+
+ } +
+ } + @else { +
+
+
Dom
+
Seg
+
Ter
+
Qua
+
Qui
+
Sex
+
Sáb
+
-
- @for (dot of day.dots; track $index) { -
+
+ @for (day of calendarMonth; track $index) { + @if (day) { +
+ {{ day.dayNumber }} +
+ @for (dot of day.dots; track $index) { +
+ } +
+
+ } @else { +
} -
+ }
- } -
+
+ }
diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index 4ba3e03..7e43481 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -1,6 +1,6 @@ -import { Component, inject, OnInit } from '@angular/core'; +import { Component, inject, OnInit, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope, Pill } from 'lucide-angular'; +import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope, Pill, ChevronLeft, ChevronRight } from 'lucide-angular'; import { Router, RouterLink } from '@angular/router'; interface QuickAction { @@ -11,7 +11,7 @@ interface QuickAction { path: string; } -interface CalendarWeek { +interface CalendarDay { dateObj: Date; dayName: string; dayNumber: number; @@ -41,16 +41,22 @@ interface HomeHighlightCard { templateUrl: './home.html', styleUrls: ['./home.css'], }) -export class HomeComponent implements OnInit { +export class HomeComponent implements OnInit, AfterViewInit { private readonly router = inject(Router); readonly ImagePlus = ImagePlus; readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; readonly Stethoscope = Stethoscope; readonly Pill = Pill; + readonly ChevronLeft = ChevronLeft; + readonly ChevronRight = ChevronRight; + + @ViewChild('daysRow') daysRow!: ElementRef; currentMonthYear: string = ''; - calendarWeek: CalendarWeek[] = []; + isExpanded = signal(false); + calendarWeek: CalendarDay[] = []; + calendarMonth: (CalendarDay | null)[] = []; selectedDate: Date = new Date(); summaryCards: HomeHighlightCard[] = [ @@ -115,21 +121,69 @@ export class HomeComponent implements OnInit { ngOnInit(): void { this.generateCurrentWeek(); + this.generateCurrentMonth(); this.updateMonthYearLabel(); } + ngAfterViewInit(): void { + this.centerActiveDay(); + } + + toggleCalendar() { + this.isExpanded.update(val => !val); + + if (!this.isExpanded()) { + this.centerActiveDay(); + } + } + + changeMonth(delta: number) { + const newDate = new Date(this.selectedDate); + newDate.setMonth(newDate.getMonth() + delta); + this.selectedDate = newDate; + + this.updateMonthYearLabel(); + this.generateCurrentWeek(); + this.generateCurrentMonth(); + } + + goToToday() { + this.selectedDate = new Date(); + this.updateMonthYearLabel(); + this.generateCurrentWeek(); + this.generateCurrentMonth(); + this.centerActiveDay(); + } + + centerActiveDay() { + setTimeout(() => { + if (!this.daysRow) return; + + const container = this.daysRow.nativeElement; + const activeCard = container.querySelector('.day-card.active') as HTMLElement; + + if (activeCard) { + activeCard.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center' + }); + } + }, 100); + } + generateCurrentWeek() { - const today = new Date(); - const currentDay = today.getDay(); + this.calendarWeek = []; + const currentDay = this.selectedDate.getDay(); - const startOfWeek = new Date(today); - startOfWeek.setDate(today.getDate() - currentDay); + const startOfScroll = new Date(this.selectedDate); + startOfScroll.setDate(this.selectedDate.getDate() - 10); const daysPt = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; - for (let i = 0; i < 7; i++) { - const dateObj = new Date(startOfWeek); - dateObj.setDate(startOfWeek.getDate() + i); + for (let i = 0; i < 21; i++) { + const dateObj = new Date(startOfScroll); + dateObj.setDate(startOfScroll.getDate() + i); this.calendarWeek.push({ dateObj, @@ -140,6 +194,30 @@ export class HomeComponent implements OnInit { } } + generateCurrentMonth() { + this.calendarMonth = []; + const year = this.selectedDate.getFullYear(); + const month = this.selectedDate.getMonth(); + + const firstDayOfMonth = new Date(year, month, 1); + const lastDayOfMonth = new Date(year, month + 1, 0); + const daysPt = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + + for (let i = 0; i < firstDayOfMonth.getDay(); i++) { + this.calendarMonth.push(null); + } + + for (let i = 1; i <= lastDayOfMonth.getDate(); i++) { + const dateObj = new Date(year, month, i); + this.calendarMonth.push({ + dateObj, + dayName: daysPt[dateObj.getDay()], + dayNumber: i, + dots: Array(Math.floor(Math.random() * 3)).fill(0), + }); + } + } + updateMonthYearLabel() { const months = [ 'Janeiro', From 8a01cec6ef6398dd34a8a10fa2b0638fb90d98c0 Mon Sep 17 00:00:00 2001 From: Rafael Luciano <74800037+rafaellucian0@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:22:14 -0300 Subject: [PATCH 48/69] PEQ-86: Implement M11 LGPD & Account (#58) * feat(lgpd): add account deletion, export, and consents (PEQ-86) * fix(lgpd): avoid duplicate enum creation in migration * fix(lgpd): address account review security gaps Co-authored-by: Matheus Ryan --- .../versions/011_create_lgpd_tables.py | 81 ++++++ backend/bruno/account/README.md | 3 + backend/bruno/account/delete_account.bru | 18 ++ backend/bruno/account/export_data.bru | 30 ++ backend/bruno/account/list_consents.bru | 19 ++ backend/bruno/account/record_consent.bru | 34 +++ backend/src/pequi/core/dependencies.py | 3 + backend/src/pequi/core/token_blacklist.py | 72 +++++ backend/src/pequi/main.py | 2 + backend/src/pequi/models/__init__.py | 2 + backend/src/pequi/models/community.py | 2 +- backend/src/pequi/models/data_deletion.py | 35 +++ backend/src/pequi/models/patient.py | 2 +- .../src/pequi/repositories/account_repo.py | 164 +++++++++++ backend/src/pequi/routers/account.py | 110 +++++++ backend/src/pequi/schemas/account.py | 22 ++ .../pequi/services/anonymization_service.py | 56 ++++ backend/src/pequi/use_cases/delete_account.py | 76 +++++ .../pequi/use_cases/export_account_data.py | 275 ++++++++++++++++++ backend/src/pequi/use_cases/record_consent.py | 38 +++ backend/src/pequi/use_cases/refresh_token.py | 4 + .../integration/test_account_deletion.py | 180 ++++++++++++ .../test_account_export_and_consents.py | 125 ++++++++ backend/tests/unit/test_auth_use_cases.py | 31 +- backend/tests/unit/test_token_blacklist.py | 40 +++ 25 files changed, 1421 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/011_create_lgpd_tables.py create mode 100644 backend/bruno/account/README.md create mode 100644 backend/bruno/account/delete_account.bru create mode 100644 backend/bruno/account/export_data.bru create mode 100644 backend/bruno/account/list_consents.bru create mode 100644 backend/bruno/account/record_consent.bru create mode 100644 backend/src/pequi/core/token_blacklist.py create mode 100644 backend/src/pequi/models/data_deletion.py create mode 100644 backend/src/pequi/repositories/account_repo.py create mode 100644 backend/src/pequi/routers/account.py create mode 100644 backend/src/pequi/schemas/account.py create mode 100644 backend/src/pequi/services/anonymization_service.py create mode 100644 backend/src/pequi/use_cases/delete_account.py create mode 100644 backend/src/pequi/use_cases/export_account_data.py create mode 100644 backend/src/pequi/use_cases/record_consent.py create mode 100644 backend/tests/integration/test_account_deletion.py create mode 100644 backend/tests/integration/test_account_export_and_consents.py create mode 100644 backend/tests/unit/test_token_blacklist.py diff --git a/backend/alembic/versions/011_create_lgpd_tables.py b/backend/alembic/versions/011_create_lgpd_tables.py new file mode 100644 index 0000000..745b9db --- /dev/null +++ b/backend/alembic/versions/011_create_lgpd_tables.py @@ -0,0 +1,81 @@ +"""create LGPD data deletion requests + +Revision ID: 011_create_lgpd_tables +Revises: 102_unique_constraints +Create Date: 2026-06-01 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "011_create_lgpd_tables" +down_revision: str | None = "102_unique_constraints" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + data_deletion_status_enum = postgresql.ENUM( + "pending", + "processing", + "completed", + "failed", + name="data_deletion_status_enum", + ) + data_deletion_status_enum.create(op.get_bind(), checkfirst=True) + + op.alter_column("patient_profiles", "date_of_birth", existing_type=sa.DATE(), nullable=True) + op.alter_column( + "community_anonymous_map", + "user_id", + existing_type=postgresql.UUID(as_uuid=True), + nullable=True, + ) + op.create_table( + "data_deletion_requests", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "requested_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("completed_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + "pending", + "processing", + "completed", + "failed", + name="data_deletion_status_enum", + create_type=False, + ), + server_default="pending", + nullable=False, + ), + sa.Column("notes", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="RESTRICT"), + ) + op.create_index( + "ix_data_deletion_requests_user_id", + "data_deletion_requests", + ["user_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_data_deletion_requests_user_id", table_name="data_deletion_requests") + op.drop_table("data_deletion_requests") + op.alter_column( + "community_anonymous_map", + "user_id", + existing_type=postgresql.UUID(as_uuid=True), + nullable=False, + ) + op.alter_column("patient_profiles", "date_of_birth", existing_type=sa.DATE(), nullable=False) + postgresql.ENUM(name="data_deletion_status_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/bruno/account/README.md b/backend/bruno/account/README.md new file mode 100644 index 0000000..84976d9 --- /dev/null +++ b/backend/bruno/account/README.md @@ -0,0 +1,3 @@ +# Account API notes + +`GET /v1/account/export` returns stored `image_url` and `image_key` values for body map records as data-access references. These values can be expired, unavailable, or already removed from object storage after account deletion. Clients must treat them as best-effort references, not guaranteed downloadable URLs. diff --git a/backend/bruno/account/delete_account.bru b/backend/bruno/account/delete_account.bru new file mode 100644 index 0000000..b9ac881 --- /dev/null +++ b/backend/bruno/account/delete_account.bru @@ -0,0 +1,18 @@ +meta { + name: Delete Account + type: http + seq: 1 +} + +delete { + url: {{baseUrl}}/v1/account + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 204 +} diff --git a/backend/bruno/account/export_data.bru b/backend/bruno/account/export_data.bru new file mode 100644 index 0000000..3444fa4 --- /dev/null +++ b/backend/bruno/account/export_data.bru @@ -0,0 +1,30 @@ +meta { + name: Export Account Data + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/account/export + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.profile: isDefined + res.body.checkins: isDefined + res.body.treatments: isDefined + res.body.dose_logs: isDefined + res.body.adherence_snapshots: isDefined + res.body.alerts: isDefined + res.body.body_map_entries: isDefined + res.body.body_area_history: isDefined + res.body.weekly_symptom_summaries: isDefined + res.body.community_posts: isDefined + res.body.consents: isDefined + res.body.exported_at: isDefined +} diff --git a/backend/bruno/account/list_consents.bru b/backend/bruno/account/list_consents.bru new file mode 100644 index 0000000..efbe5fd --- /dev/null +++ b/backend/bruno/account/list_consents.bru @@ -0,0 +1,19 @@ +meta { + name: List Consents + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/v1/account/consents + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body: isArray +} diff --git a/backend/bruno/account/record_consent.bru b/backend/bruno/account/record_consent.bru new file mode 100644 index 0000000..4c72d08 --- /dev/null +++ b/backend/bruno/account/record_consent.bru @@ -0,0 +1,34 @@ +meta { + name: Record Consent + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/v1/account/consent + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json + User-Agent: PequiApp/1.0 +} + +body:json { + { + "term_version": "v1.2", + "accepted": true + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.term_version: eq v1.2 + res.body.accepted_at: isDefined +} diff --git a/backend/src/pequi/core/dependencies.py b/backend/src/pequi/core/dependencies.py index 1ed0a92..dc0c170 100644 --- a/backend/src/pequi/core/dependencies.py +++ b/backend/src/pequi/core/dependencies.py @@ -16,6 +16,7 @@ from pequi.config import get_settings from pequi.core.auth import TOKEN_TYPE_ACCESS, JWTError, decode_token from pequi.core.exceptions import ForbiddenError, UnauthorizedError +from pequi.core.token_blacklist import is_token_revoked from pequi.database import get_db as _get_db from pequi.integrations import AIClient, ObjectStorageClient, WhatsAppClient, get_anthropic_client from pequi.repositories.patient_repo import PatientRepository @@ -43,6 +44,8 @@ async def get_token_payload( if payload.get("type") != TOKEN_TYPE_ACCESS: raise UnauthorizedError("Access token required") + if await is_token_revoked(payload): + raise UnauthorizedError("Token revoked") return payload diff --git a/backend/src/pequi/core/token_blacklist.py b/backend/src/pequi/core/token_blacklist.py new file mode 100644 index 0000000..332af72 --- /dev/null +++ b/backend/src/pequi/core/token_blacklist.py @@ -0,0 +1,72 @@ +from datetime import UTC, datetime +from uuid import UUID + +from redis.asyncio import Redis +from redis.exceptions import RedisError + +from pequi.config import get_settings + +_blacklisted_jtis: set[str] = set() +_revoked_user_after: dict[str, int] = {} +_redis: Redis | None = None + + +def _settings_use_redis() -> bool: + return bool(get_settings().REDIS_URL) + + +def _get_redis() -> Redis: + global _redis + if _redis is None: + _redis = Redis.from_url(get_settings().REDIS_URL, decode_responses=True) + return _redis + + +async def blacklist_token(jti: str | None, exp: int | None) -> None: + if not jti: + return + _blacklisted_jtis.add(jti) + if _settings_use_redis(): + ttl = max((exp or 0) - int(datetime.now(UTC).timestamp()), 1) + try: + await _get_redis().setex(f"blacklist:{jti}", ttl, "1") + except RedisError: + return + + +async def revoke_user_tokens(user_id: UUID, revoked_at: datetime | None = None) -> None: + value = int((revoked_at or datetime.now(UTC)).timestamp()) + _revoked_user_after[str(user_id)] = value + if _settings_use_redis(): + ttl = get_settings().REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + try: + await _get_redis().setex(f"user_revoked_after:{user_id}", ttl, str(value)) + except RedisError: + return + + +async def is_token_revoked(payload: dict) -> bool: + jti = payload.get("jti") + if jti and jti in _blacklisted_jtis: + return True + + sub = payload.get("sub") + iat = payload.get("iat") + revoked_after = _revoked_user_after.get(str(sub)) + if revoked_after is not None and isinstance(iat, int) and iat <= revoked_after: + return True + + if not _settings_use_redis(): + return False + + redis = _get_redis() + try: + if jti and await redis.exists(f"blacklist:{jti}"): + return True + if sub and iat: + value = await redis.get(f"user_revoked_after:{sub}") + if value is not None and int(iat) <= int(value): + return True + except RedisError: + return False + return False diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 61bd37e..3bd510a 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -71,6 +71,7 @@ async def health_check() -> JSONResponse: app.include_router(health_router) + from pequi.routers import account as account_router from pequi.routers import article as article_router from pequi.routers import auth as auth_router from pequi.routers import body_map as body_map_router @@ -80,6 +81,7 @@ async def health_check() -> JSONResponse: from pequi.routers import treatment as treatment_router app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) + app.include_router(account_router.router, prefix="/v1/account", tags=["account"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) app.include_router(treatment_router.router, prefix="/v1/treatments", tags=["treatments"]) app.include_router(treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"]) diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index f2b5df3..10fd4b1 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -10,6 +10,7 @@ CommunityPost, ) from pequi.models.consent import Consent +from pequi.models.data_deletion import DataDeletionRequest from pequi.models.dose_log import AdherenceSnapshot, DoseLog from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit @@ -35,6 +36,7 @@ "CommunityLike", "CommunityPost", "Consent", + "DataDeletionRequest", "DoseLog", "DoseSchedule", "HealthProfessional", diff --git a/backend/src/pequi/models/community.py b/backend/src/pequi/models/community.py index 891f04b..e6c0cd7 100644 --- a/backend/src/pequi/models/community.py +++ b/backend/src/pequi/models/community.py @@ -20,7 +20,7 @@ class CommunityAnonymousMap(Base): UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), unique=True, - nullable=False, + nullable=True, index=True, ) anonymous_id = Column( diff --git a/backend/src/pequi/models/data_deletion.py b/backend/src/pequi/models/data_deletion.py new file mode 100644 index 0000000..f49c189 --- /dev/null +++ b/backend/src/pequi/models/data_deletion.py @@ -0,0 +1,35 @@ +import uuid +from enum import StrEnum + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class DataDeletionStatus(StrEnum): + pending = "pending" + processing = "processing" + completed = "completed" + failed = "failed" + + +class DataDeletionRequest(Base): + __tablename__ = "data_deletion_requests" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + requested_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + completed_at = Column(DateTime(timezone=True), nullable=True) + status = Column( + Enum(DataDeletionStatus, name="data_deletion_status_enum"), + nullable=False, + server_default=DataDeletionStatus.pending.value, + ) + notes = Column(Text, nullable=True) diff --git a/backend/src/pequi/models/patient.py b/backend/src/pequi/models/patient.py index 014c0c5..bbb25ea 100644 --- a/backend/src/pequi/models/patient.py +++ b/backend/src/pequi/models/patient.py @@ -21,7 +21,7 @@ class PatientProfile(Base): ForeignKey("health_units.id", ondelete="RESTRICT"), nullable=False, ) - date_of_birth = Column(Date, nullable=False) + date_of_birth = Column(Date, nullable=True) sex = Column(String(10)) neighborhood = Column(String) city = Column(String) diff --git a/backend/src/pequi/repositories/account_repo.py b/backend/src/pequi/repositories/account_repo.py new file mode 100644 index 0000000..e3a9586 --- /dev/null +++ b/backend/src/pequi/repositories/account_repo.py @@ -0,0 +1,164 @@ +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from pequi.models.alert import Alert +from pequi.models.body_map import BodyAreaHistory, BodyMapEntry +from pequi.models.checkin import Checkin +from pequi.models.community import CommunityAnonymousMap, CommunityComment, CommunityPost +from pequi.models.consent import Consent +from pequi.models.data_deletion import DataDeletionRequest, DataDeletionStatus +from pequi.models.dose_log import AdherenceSnapshot, DoseLog +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentStatus +from pequi.models.user import User +from pequi.models.weekly_summary import WeeklySymptomSummary + + +class AccountRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_active_user(self, user_id: UUID) -> User | None: + stmt = select(User).where(User.id == user_id, User.deleted_at.is_(None)) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_patient_by_user_id(self, user_id: UUID) -> PatientProfile | None: + stmt = select(PatientProfile).where( + PatientProfile.user_id == user_id, + PatientProfile.deleted_at.is_(None), + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def has_active_treatment(self, patient_id: UUID) -> bool: + stmt = select(Treatment.id).where( + Treatment.patient_id == patient_id, + Treatment.status == TreatmentStatus.active, + Treatment.deleted_at.is_(None), + ) + return (await self._session.execute(stmt)).scalar_one_or_none() is not None + + async def create_deletion_request(self, user_id: UUID) -> DataDeletionRequest: + request = DataDeletionRequest( + user_id=user_id, + status=DataDeletionStatus.processing, + ) + self._session.add(request) + await self._session.flush() + return request + + async def complete_deletion_request(self, request: DataDeletionRequest) -> None: + request.status = DataDeletionStatus.completed + request.completed_at = datetime.now(UTC) + await self._session.flush() + + async def fail_deletion_request(self, request: DataDeletionRequest, notes: str) -> None: + request.status = DataDeletionStatus.failed + request.completed_at = datetime.now(UTC) + request.notes = notes[:1000] + await self._session.flush() + + async def list_body_map_entries(self, patient_id: UUID) -> list[BodyMapEntry]: + stmt = select(BodyMapEntry).where(BodyMapEntry.patient_id == patient_id) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_body_area_history(self, patient_id: UUID) -> list[BodyAreaHistory]: + stmt = select(BodyAreaHistory).where(BodyAreaHistory.patient_id == patient_id) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_checkins(self, patient_id: UUID) -> list[Checkin]: + stmt = ( + select(Checkin) + .where(Checkin.patient_id == patient_id) + .options(selectinload(Checkin.symptoms)) + .order_by(Checkin.checked_in_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_treatments(self, patient_id: UUID) -> list[Treatment]: + stmt = select(Treatment).where(Treatment.patient_id == patient_id) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_dose_logs(self, patient_id: UUID) -> list[DoseLog]: + stmt = ( + select(DoseLog) + .join(Treatment, DoseLog.treatment_id == Treatment.id) + .where(Treatment.patient_id == patient_id) + .order_by(DoseLog.expected_at) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_adherence_snapshots(self, patient_id: UUID) -> list[AdherenceSnapshot]: + stmt = ( + select(AdherenceSnapshot) + .where(AdherenceSnapshot.patient_id == patient_id) + .order_by(AdherenceSnapshot.calculated_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_weekly_symptom_summaries(self, patient_id: UUID) -> list[WeeklySymptomSummary]: + stmt = ( + select(WeeklySymptomSummary) + .where(WeeklySymptomSummary.patient_id == patient_id) + .order_by(WeeklySymptomSummary.week_start.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_alerts(self, patient_id: UUID) -> list[Alert]: + stmt = select(Alert).where(Alert.patient_id == patient_id).order_by(Alert.created_at.desc()) + return list((await self._session.execute(stmt)).scalars().all()) + + async def get_anonymous_mapping(self, user_id: UUID) -> CommunityAnonymousMap | None: + stmt = select(CommunityAnonymousMap).where(CommunityAnonymousMap.user_id == user_id) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def unlink_anonymous_mapping(self, user_id: UUID) -> None: + mapping = await self.get_anonymous_mapping(user_id) + if mapping is None: + return + mapping.user_id = None + await self._session.flush() + + async def list_community_posts(self, anonymous_id: UUID) -> list[CommunityPost]: + stmt = ( + select(CommunityPost) + .where(CommunityPost.author_anonymous_id == anonymous_id) + .order_by(CommunityPost.created_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def list_community_comments(self, anonymous_id: UUID) -> list[CommunityComment]: + stmt = ( + select(CommunityComment) + .where(CommunityComment.author_anonymous_id == anonymous_id) + .order_by(CommunityComment.created_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def add_consent( + self, + *, + user_id: UUID, + term_version: str, + ip_address: str | None, + user_agent: str | None, + ) -> Consent: + consent = Consent( + user_id=user_id, + term_version=term_version, + ip_address=ip_address, + user_agent=user_agent, + ) + self._session.add(consent) + await self._session.flush() + await self._session.refresh(consent) + return consent + + async def list_consents(self, user_id: UUID) -> list[Consent]: + stmt = ( + select(Consent).where(Consent.user_id == user_id).order_by(Consent.accepted_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) diff --git a/backend/src/pequi/routers/account.py b/backend/src/pequi/routers/account.py new file mode 100644 index 0000000..ab70989 --- /dev/null +++ b/backend/src/pequi/routers/account.py @@ -0,0 +1,110 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import ( + get_actor_from_token, + get_current_patient, + get_db, + get_object_storage_client, + get_token_payload, +) +from pequi.core.rate_limit import user_limiter +from pequi.integrations.object_storage import ObjectStorageClient +from pequi.schemas.account import ConsentCreate, ConsentResponse +from pequi.use_cases.delete_account import DeleteAccountUseCase +from pequi.use_cases.export_account_data import ExportAccountDataUseCase +from pequi.use_cases.record_consent import ListConsentsUseCase, RecordConsentUseCase + +router = APIRouter() + + +def _client_ip(request: Request) -> str | None: + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + return forwarded_for.split(",", maxsplit=1)[0].strip() + return request.client.host if request.client else None + + +def get_delete_account_use_case( + session: Annotated[AsyncSession, Depends(get_db)], + storage: Annotated[ObjectStorageClient, Depends(get_object_storage_client)], +) -> DeleteAccountUseCase: + return DeleteAccountUseCase(session, storage=storage) + + +def get_export_account_data_use_case( + session: Annotated[AsyncSession, Depends(get_db)], +) -> ExportAccountDataUseCase: + return ExportAccountDataUseCase(session) + + +def get_record_consent_use_case( + session: Annotated[AsyncSession, Depends(get_db)], +) -> RecordConsentUseCase: + return RecordConsentUseCase(session) + + +def get_list_consents_use_case( + session: Annotated[AsyncSession, Depends(get_db)], +) -> ListConsentsUseCase: + return ListConsentsUseCase(session) + + +@router.delete("", status_code=204) +@user_limiter.limit("1/hour") +async def delete_account( + request: Request, + response: Response, + user_id: Annotated[UUID, Depends(get_current_patient)], + payload: Annotated[dict, Depends(get_token_payload)], + use_case: Annotated[DeleteAccountUseCase, Depends(get_delete_account_use_case)], +) -> Response: + await use_case.execute( + user_id=user_id, + token_jti=payload.get("jti"), + token_exp=payload.get("exp"), + ip_address=_client_ip(request), + ) + return response + + +@router.get("/export") +@user_limiter.limit("1/hour") +async def export_account_data( + request: Request, + user_id: Annotated[UUID, Depends(get_current_patient)], + use_case: Annotated[ExportAccountDataUseCase, Depends(get_export_account_data_use_case)], +) -> dict: + return await use_case.execute(user_id, ip_address=_client_ip(request)) + + +@router.post("/consent", response_model=ConsentResponse, status_code=201) +@user_limiter.limit("5/hour") +async def record_consent( + request: Request, + body: ConsentCreate, + actor: Annotated[tuple[UUID, str], Depends(get_actor_from_token)], + use_case: Annotated[RecordConsentUseCase, Depends(get_record_consent_use_case)], +) -> ConsentResponse: + user_id, _role = actor + consent = await use_case.execute( + user_id=user_id, + data=body, + ip_address=_client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + return ConsentResponse.model_validate(consent) + + +@router.get("/consents", response_model=list[ConsentResponse]) +@user_limiter.limit("20/minute") +async def list_consents( + request: Request, + actor: Annotated[tuple[UUID, str], Depends(get_actor_from_token)], + use_case: Annotated[ListConsentsUseCase, Depends(get_list_consents_use_case)], +) -> list[ConsentResponse]: + user_id, _role = actor + return [ConsentResponse.model_validate(row) for row in await use_case.execute(user_id)] diff --git a/backend/src/pequi/schemas/account.py b/backend/src/pequi/schemas/account.py new file mode 100644 index 0000000..7abea12 --- /dev/null +++ b/backend/src/pequi/schemas/account.py @@ -0,0 +1,22 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class ConsentCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + term_version: str = Field(..., min_length=1, max_length=50) + accepted: bool + + +class ConsentResponse(BaseModel): + id: UUID + user_id: UUID + term_version: str + accepted_at: datetime + ip_address: str | None = None + user_agent: str | None = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/services/anonymization_service.py b/backend/src/pequi/services/anonymization_service.py new file mode 100644 index 0000000..d7a2692 --- /dev/null +++ b/backend/src/pequi/services/anonymization_service.py @@ -0,0 +1,56 @@ +from datetime import UTC, datetime +from hashlib import sha256 +from typing import Protocol +from uuid import uuid4 + +from pequi.core.auth import hash_password +from pequi.models.body_map import BodyAreaHistory, BodyMapEntry +from pequi.models.patient import PatientProfile +from pequi.models.user import User + + +class ObjectDeleter(Protocol): + async def delete(self, key: str) -> None: ... + + +class AnonymizationService: + async def anonymize_account( + self, + *, + user: User, + patient: PatientProfile, + body_map_entries: list[BodyMapEntry], + body_area_history: list[BodyAreaHistory], + storage: ObjectDeleter, + ) -> None: + now = datetime.now(UTC) + digest = sha256(str(user.id).encode("utf-8")).hexdigest() + + user.email = f"deleted:{digest}@anonymous.local" + user.full_name = "Usuario Removido" + user.hashed_password = hash_password(f"deleted:{uuid4()}:{digest}") + user.is_active = False + user.deleted_at = now + + patient.date_of_birth = None + patient.sex = None + patient.neighborhood = None + patient.city = None + patient.state = None + patient.diagnosis_date = None + patient.classification = None + patient.deleted_at = now + + await self._delete_body_map_media(body_map_entries, storage) + await self._delete_body_map_media(body_area_history, storage) + + async def _delete_body_map_media( + self, + rows: list[BodyMapEntry] | list[BodyAreaHistory], + storage: ObjectDeleter, + ) -> None: + for row in rows: + if row.image_key: + await storage.delete(row.image_key) + row.image_key = None + row.image_url = None diff --git a/backend/src/pequi/use_cases/delete_account.py b/backend/src/pequi/use_cases/delete_account.py new file mode 100644 index 0000000..7ba2f7d --- /dev/null +++ b/backend/src/pequi/use_cases/delete_account.py @@ -0,0 +1,76 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.exceptions import ConflictError, NotFoundError +from pequi.core.token_blacklist import blacklist_token, revoke_user_tokens +from pequi.repositories.account_repo import AccountRepository +from pequi.repositories.audit_repo import AuditRepository +from pequi.services.anonymization_service import AnonymizationService, ObjectDeleter + + +class DeleteAccountUseCase: + def __init__( + self, + session: AsyncSession, + *, + storage: ObjectDeleter, + anonymization_service: AnonymizationService | None = None, + ) -> None: + self._session = session + self._repo = AccountRepository(session) + self._audit_repo = AuditRepository(session) + self._storage = storage + self._anonymization_service = anonymization_service or AnonymizationService() + + async def execute( + self, + *, + user_id: UUID, + token_jti: str | None, + token_exp: int | None = None, + ip_address: str | None = None, + ) -> None: + user = await self._repo.get_active_user(user_id) + if user is None: + raise NotFoundError("User", str(user_id)) + + patient = await self._repo.get_patient_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile", str(user_id)) + + if await self._repo.has_active_treatment(patient.id): + raise ConflictError( + "Nao e possivel excluir a conta com tratamento ativo. " + "Contate seu profissional de saude." + ) + + deletion_request = await self._repo.create_deletion_request(user_id) + try: + async with self._session.begin_nested(): + await self._anonymization_service.anonymize_account( + user=user, + patient=patient, + body_map_entries=await self._repo.list_body_map_entries(patient.id), + body_area_history=await self._repo.list_body_area_history(patient.id), + storage=self._storage, + ) + await self._repo.unlink_anonymous_mapping(user_id) + await blacklist_token(token_jti, token_exp) + await revoke_user_tokens(user_id) + await self._audit_repo.log_action( + actor_user_id=user_id, + actor_role="patient", + entity_type="account", + entity_id=str(user_id), + action="ACCOUNT_DELETION", + details=f"data_deletion_request_id={deletion_request.id}", + ip_address=ip_address, + ) + await self._repo.complete_deletion_request(deletion_request) + except Exception as exc: + await self._repo.fail_deletion_request( + deletion_request, + notes=f"{type(exc).__name__}: account deletion failed", + ) + raise diff --git a/backend/src/pequi/use_cases/export_account_data.py b/backend/src/pequi/use_cases/export_account_data.py new file mode 100644 index 0000000..8a41999 --- /dev/null +++ b/backend/src/pequi/use_cases/export_account_data.py @@ -0,0 +1,275 @@ +from datetime import UTC, date, datetime +from decimal import Decimal +from enum import Enum +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.account_repo import AccountRepository +from pequi.repositories.audit_repo import AuditRepository + + +class ExportAccountDataUseCase: + def __init__(self, session: AsyncSession) -> None: + self._repo = AccountRepository(session) + self._audit_repo = AuditRepository(session) + + async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict: + user = await self._repo.get_active_user(user_id) + if user is None: + raise NotFoundError("User", str(user_id)) + patient = await self._repo.get_patient_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile", str(user_id)) + + mapping = await self._repo.get_anonymous_mapping(user_id) + posts = await self._repo.list_community_posts(mapping.anonymous_id) if mapping else [] + comments = await self._repo.list_community_comments(mapping.anonymous_id) if mapping else [] + + await self._audit_repo.log_action( + actor_user_id=user_id, + actor_role=user.role, + entity_type="account", + entity_id=str(user_id), + action="ACCOUNT_EXPORT", + ip_address=ip_address, + ) + + return { + "profile": { + "user": self._dump( + user, + include=[ + "id", + "email", + "full_name", + "role", + "is_active", + "is_verified", + "created_at", + "updated_at", + ], + ), + "patient": self._dump( + patient, + include=[ + "id", + "user_id", + "health_unit_id", + "date_of_birth", + "sex", + "neighborhood", + "city", + "state", + "disability_grade", + "diagnosis_date", + "classification", + "notifications_enabled", + "created_at", + "updated_at", + ], + ), + }, + "checkins": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "mood", + "symptom_intensity", + "general_notes", + "ai_feedback", + "ai_feedback_at", + "checked_in_at", + "created_at", + ], + extra={"symptom_ids": [str(symptom.id) for symptom in row.symptoms]}, + ) + for row in await self._repo.list_checkins(patient.id) + ], + "treatments": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "prescribed_by", + "regimen", + "start_date", + "expected_end", + "status", + "notes", + "created_at", + "updated_at", + ], + ) + for row in await self._repo.list_treatments(patient.id) + ], + "dose_logs": [ + self._dump( + row, + include=[ + "id", + "treatment_id", + "drug_name", + "expected_at", + "taken_at", + "skipped", + "skip_reason", + "supervised", + "registered_by", + "created_at", + ], + ) + for row in await self._repo.list_dose_logs(patient.id) + ], + "adherence_snapshots": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "treatment_id", + "period_start", + "period_end", + "total_doses", + "taken_doses", + "adherence_pct", + "calculated_at", + ], + ) + for row in await self._repo.list_adherence_snapshots(patient.id) + ], + "alerts": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "checkin_id", + "type", + "severity", + "resolved", + "resolved_at", + "resolved_by", + "notes", + "created_at", + "updated_at", + ], + ) + for row in await self._repo.list_alerts(patient.id) + ], + "body_map_entries": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "body_area_id", + "finding_type", + "intensity", + "image_url", + "image_key", + "notes", + "recorded_at", + "created_at", + "deleted_at", + ], + ) + for row in await self._repo.list_body_map_entries(patient.id) + ], + "body_area_history": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "checkin_id", + "body_area_id", + "finding_type", + "intensity", + "image_url", + "image_key", + "snapshot_at", + ], + ) + for row in await self._repo.list_body_area_history(patient.id) + ], + "weekly_symptom_summaries": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "week_start", + "week_end", + "avg_intensity", + "dominant_mood", + "checkin_count", + "alert_count", + "calculated_at", + ], + ) + for row in await self._repo.list_weekly_symptom_summaries(patient.id) + ], + "community_posts": [ + self._dump( + row, + include=[ + "id", + "title", + "content", + "category", + "is_pinned", + "is_moderated", + "like_count", + "comment_count", + "created_at", + "updated_at", + "deleted_at", + ], + ) + for row in posts + ], + "community_comments": [ + self._dump( + row, + include=["id", "post_id", "content", "created_at", "updated_at", "deleted_at"], + ) + for row in comments + ], + "consents": [ + self._dump( + row, + include=[ + "id", + "user_id", + "term_version", + "accepted_at", + "ip_address", + "user_agent", + ], + ) + for row in await self._repo.list_consents(user_id) + ], + "exported_at": datetime.now(UTC).isoformat(), + } + + def _dump(self, obj, *, include: list[str], extra: dict | None = None) -> dict: + data = {key: self._json_value(getattr(obj, key)) for key in include} + if extra: + data.update(extra) + return data + + def _json_value(self, value): + if isinstance(value, UUID): + return str(value) + if isinstance(value, datetime | date): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + if isinstance(value, Enum): + return value.value + return value diff --git a/backend/src/pequi/use_cases/record_consent.py b/backend/src/pequi/use_cases/record_consent.py new file mode 100644 index 0000000..c4bfb5b --- /dev/null +++ b/backend/src/pequi/use_cases/record_consent.py @@ -0,0 +1,38 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.exceptions import ValidationFailedError +from pequi.models.consent import Consent +from pequi.repositories.account_repo import AccountRepository +from pequi.schemas.account import ConsentCreate + + +class RecordConsentUseCase: + def __init__(self, session: AsyncSession) -> None: + self._repo = AccountRepository(session) + + async def execute( + self, + *, + user_id: UUID, + data: ConsentCreate, + ip_address: str | None, + user_agent: str | None, + ) -> Consent: + if not data.accepted: + raise ValidationFailedError("Consentimento deve ser aceito explicitamente.") + return await self._repo.add_consent( + user_id=user_id, + term_version=data.term_version, + ip_address=ip_address, + user_agent=user_agent, + ) + + +class ListConsentsUseCase: + def __init__(self, session: AsyncSession) -> None: + self._repo = AccountRepository(session) + + async def execute(self, user_id: UUID) -> list[Consent]: + return await self._repo.list_consents(user_id) diff --git a/backend/src/pequi/use_cases/refresh_token.py b/backend/src/pequi/use_cases/refresh_token.py index e381113..08a8557 100644 --- a/backend/src/pequi/use_cases/refresh_token.py +++ b/backend/src/pequi/use_cases/refresh_token.py @@ -11,6 +11,7 @@ is_token_type, ) from pequi.core.exceptions import UnauthorizedError +from pequi.core.token_blacklist import is_token_revoked from pequi.repositories.user_repo import UserRepository from pequi.schemas.user import AuthResponse, RefreshRequest, UserResponse @@ -30,6 +31,9 @@ async def execute(self, data: RefreshRequest) -> AuthResponse: if not is_token_type(payload, TOKEN_TYPE_REFRESH): raise UnauthorizedError("Invalid token type") + if await is_token_revoked(payload): + raise UnauthorizedError("Token revoked") + user_id = UUID(payload["sub"]) user = await self.user_repo.get_by_id(user_id) diff --git a/backend/tests/integration/test_account_deletion.py b/backend/tests/integration/test_account_deletion.py new file mode 100644 index 0000000..f1bb144 --- /dev/null +++ b/backend/tests/integration/test_account_deletion.py @@ -0,0 +1,180 @@ +from datetime import UTC, date, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.exceptions import ConflictError +from pequi.core.token_blacklist import is_token_revoked +from pequi.models.audit_log import AuditLog +from pequi.models.body_map import BodyArea, BodyMapEntry +from pequi.models.community import CommunityAnonymousMap, CommunityPost +from pequi.models.health_professional import HealthProfessional +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User +from pequi.use_cases.delete_account import DeleteAccountUseCase + +pytestmark = pytest.mark.asyncio + + +class RecordingStorage: + def __init__(self) -> None: + self.deleted_keys: list[str] = [] + + async def delete(self, key: str) -> None: + self.deleted_keys.append(key) + + +async def _create_patient(db_session: AsyncSession) -> tuple[User, PatientProfile]: + unit = HealthUnit(name="UBS", city="Cidade", state="SP", cnes=str(uuid4())[:12]) + user = User( + email=f"{uuid4()}@example.com", + hashed_password="$2b$12$YyAjsZqFgKMm.yK7hyRre.eD0mgoTb1foL3.zIg0SBsDzz8qyPZ7G", + full_name="Paciente Teste", + role="patient", + ) + db_session.add_all([unit, user]) + await db_session.flush() + + patient = PatientProfile( + user_id=user.id, + health_unit_id=unit.id, + date_of_birth=date(1990, 1, 1), + neighborhood="Centro", + city="Cidade", + state="SP", + sex="F", + diagnosis_date=date(2024, 1, 1), + classification="PB", + ) + db_session.add(patient) + await db_session.flush() + return user, patient + + +async def _create_professional(db_session: AsyncSession, unit_id) -> HealthProfessional: + user = User( + email=f"prof-{uuid4()}@example.com", + hashed_password="hash", + full_name="Profissional", + role="health_professional", + ) + db_session.add(user) + await db_session.flush() + professional = HealthProfessional(user_id=user.id, health_unit_id=unit_id) + db_session.add(professional) + await db_session.flush() + return professional + + +async def test_delete_account_blocks_active_treatment(create_tables, db_session: AsyncSession): + user, patient = await _create_patient(db_session) + professional = await _create_professional(db_session, patient.health_unit_id) + treatment = Treatment( + patient_id=patient.id, + prescribed_by=professional.id, + regimen=TreatmentRegimen.PB, + start_date=date.today(), + expected_end=date.today() + timedelta(days=180), + status=TreatmentStatus.active, + ) + db_session.add(treatment) + await db_session.flush() + + use_case = DeleteAccountUseCase(db_session, storage=RecordingStorage()) + + with pytest.raises(ConflictError, match="tratamento ativo"): + await use_case.execute(user_id=user.id, token_jti="token-jti", ip_address="127.0.0.1") + + +async def test_delete_account_anonymizes_pii_deletes_media_and_audits( + create_tables, db_session: AsyncSession +): + user, patient = await _create_patient(db_session) + original_password_hash = user.hashed_password + area = BodyArea(code="hand_left", label="Mao esquerda", side="left", system_part="upper_limb") + db_session.add(area) + await db_session.flush() + entry = BodyMapEntry( + patient_id=patient.id, + body_area_id=area.id, + finding_type="lesion", + intensity=2, + image_url="https://storage.test/body-map/a.jpg", + image_key="body-map/a.jpg", + ) + db_session.add(entry) + await db_session.flush() + + storage = RecordingStorage() + use_case = DeleteAccountUseCase(db_session, storage=storage) + + await use_case.execute(user_id=user.id, token_jti="token-jti", ip_address="127.0.0.1") + + assert user.email.startswith("deleted:") + assert user.full_name == "Usuario Removido" + assert user.hashed_password != original_password_hash + assert user.is_active is False + assert user.deleted_at is not None + assert patient.date_of_birth is None + assert patient.neighborhood is None + assert patient.city is None + assert patient.state is None + assert patient.sex is None + assert patient.diagnosis_date is None + assert patient.classification is None + assert patient.deleted_at is not None + assert entry.image_key is None + assert entry.image_url is None + assert "body-map/a.jpg" in storage.deleted_keys + + audit = ( + await db_session.execute( + select(AuditLog).where( + AuditLog.actor_user_id == user.id, + AuditLog.action == "ACCOUNT_DELETION", + ) + ) + ).scalar_one() + assert audit.entity_type == "account" + + +async def test_delete_account_revokes_token_and_unlinks_anonymous_mapping( + create_tables, db_session: AsyncSession +): + user, _patient = await _create_patient(db_session) + mapping = CommunityAnonymousMap(user_id=user.id) + db_session.add(mapping) + await db_session.flush() + post = CommunityPost( + author_anonymous_id=mapping.anonymous_id, + title="Relato", + content="Conteudo publico", + category="experience", + ) + db_session.add(post) + await db_session.flush() + + token_iat = int((datetime.now(UTC) - timedelta(minutes=1)).timestamp()) + token_exp = int((datetime.now(UTC) + timedelta(minutes=30)).timestamp()) + + await DeleteAccountUseCase(db_session, storage=RecordingStorage()).execute( + user_id=user.id, + token_jti="token-jti-to-revoke", + token_exp=token_exp, + ip_address="127.0.0.1", + ) + + assert await is_token_revoked( + { + "sub": str(user.id), + "jti": "token-jti-to-revoke", + "iat": token_iat, + "exp": token_exp, + } + ) + assert mapping.user_id is None + assert post.author_anonymous_id == mapping.anonymous_id diff --git a/backend/tests/integration/test_account_export_and_consents.py b/backend/tests/integration/test_account_export_and_consents.py new file mode 100644 index 0000000..8a8d2d2 --- /dev/null +++ b/backend/tests/integration/test_account_export_and_consents.py @@ -0,0 +1,125 @@ +from datetime import UTC, date, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.alert import Alert, AlertSeverity, AlertType +from pequi.models.audit_log import AuditLog +from pequi.models.checkin import Checkin, CheckinMood +from pequi.models.community import CommunityAnonymousMap, CommunityPost +from pequi.models.consent import Consent +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.user import User +from pequi.schemas.account import ConsentCreate +from pequi.use_cases.export_account_data import ExportAccountDataUseCase +from pequi.use_cases.record_consent import ListConsentsUseCase, RecordConsentUseCase + +pytestmark = pytest.mark.asyncio + + +async def _create_patient(db_session: AsyncSession) -> tuple[User, PatientProfile]: + unit = HealthUnit(name="UBS", city="Cidade", state="SP", cnes=str(uuid4())[:12]) + user = User( + email=f"{uuid4()}@example.com", + hashed_password="hash", + full_name="Paciente Teste", + role="patient", + ) + db_session.add_all([unit, user]) + await db_session.flush() + patient = PatientProfile( + user_id=user.id, + health_unit_id=unit.id, + date_of_birth=date(1990, 1, 1), + neighborhood="Centro", + city="Cidade", + state="SP", + ) + db_session.add(patient) + await db_session.flush() + return user, patient + + +async def test_export_account_data_includes_profile_clinical_community_and_consents( + create_tables, db_session: AsyncSession +): + user, patient = await _create_patient(db_session) + checkin = Checkin( + patient_id=patient.id, + mood=CheckinMood.ok, + symptom_intensity=5, + general_notes="Dor leve", + ) + alert = Alert( + patient_id=patient.id, + checkin_id=None, + type=AlertType.symptom_spike, + severity=AlertSeverity.high, + ) + mapping = CommunityAnonymousMap(user_id=user.id) + consent = Consent( + user_id=user.id, + term_version="v1.2", + ip_address="127.0.0.1", + user_agent="pytest", + ) + db_session.add_all([checkin, alert, mapping, consent]) + await db_session.flush() + post = CommunityPost( + author_anonymous_id=mapping.anonymous_id, + title="Minha jornada", + content="Conteudo publico", + category="experience", + ) + db_session.add(post) + await db_session.flush() + + exported = await ExportAccountDataUseCase(db_session).execute( + user.id, + ip_address="127.0.0.1", + ) + + assert exported["profile"]["user"]["email"] == user.email + assert exported["profile"]["patient"]["id"] == str(patient.id) + assert exported["checkins"][0]["general_notes"] == "Dor leve" + assert exported["alerts"][0]["type"] == "symptom_spike" + assert exported["body_map_entries"] == [] + assert exported["adherence_snapshots"] == [] + assert exported["weekly_symptom_summaries"] == [] + assert exported["community_posts"][0]["title"] == "Minha jornada" + assert "author_anonymous_id" not in exported["community_posts"][0] + assert exported["consents"][0]["term_version"] == "v1.2" + assert datetime.fromisoformat(exported["exported_at"]).tzinfo is not None + + audit = ( + await db_session.execute( + select(AuditLog).where( + AuditLog.actor_user_id == user.id, + AuditLog.action == "ACCOUNT_EXPORT", + ) + ) + ).scalar_one() + assert audit.entity_type == "account" + assert audit.ip_address == "127.0.0.1" + + +async def test_record_and_list_consents_capture_ip_and_user_agent( + create_tables, db_session: AsyncSession +): + user, _patient = await _create_patient(db_session) + + recorded = await RecordConsentUseCase(db_session).execute( + user_id=user.id, + data=ConsentCreate(term_version="v1.2", accepted=True), + ip_address="203.0.113.10", + user_agent="PequiApp/1.0", + ) + listed = await ListConsentsUseCase(db_session).execute(user.id) + + assert recorded.term_version == "v1.2" + assert str(recorded.ip_address) == "203.0.113.10" + assert recorded.user_agent == "PequiApp/1.0" + assert listed[0].accepted_at.replace(tzinfo=UTC).isoformat() diff --git a/backend/tests/unit/test_auth_use_cases.py b/backend/tests/unit/test_auth_use_cases.py index 620b717..72ae9ff 100644 --- a/backend/tests/unit/test_auth_use_cases.py +++ b/backend/tests/unit/test_auth_use_cases.py @@ -5,8 +5,9 @@ import pytest from pequi.core.exceptions import ConflictError, UnauthorizedError -from pequi.schemas.user import LoginRequest, UserCreate +from pequi.schemas.user import LoginRequest, RefreshRequest, UserCreate from pequi.use_cases.login_user import LoginUserUseCase +from pequi.use_cases.refresh_token import RefreshTokenUseCase from pequi.use_cases.register_user import RegisterUserUseCase pytestmark = pytest.mark.asyncio @@ -47,6 +48,11 @@ async def add(self, user): user.updated_at = datetime(2026, 1, 1, tzinfo=UTC) return user + async def get_by_id(self, user_id): + if self.existing_user is None or self.existing_user.id != user_id: + return None + return self.existing_user + def _hashed_password(_password): return "hashed" @@ -68,6 +74,10 @@ def _refresh_token(**_kwargs): return "refresh" +async def _token_is_revoked(_payload): + return True + + async def test_register_user_hashes_password_and_always_creates_patient(monkeypatch): repo = FakeUserRepository() monkeypatch.setattr("pequi.use_cases.register_user.hash_password", _hashed_password) @@ -146,3 +156,22 @@ async def test_login_user_rejects_inactive_user(monkeypatch): await use_case.execute( LoginRequest(email="inactive@example.com", password="strongpassword123") ) + + +async def test_refresh_token_rejects_revoked_token(monkeypatch): + user = _user() + payload = { + "sub": str(user.id), + "role": "patient", + "type": "refresh", + "jti": "revoked-refresh", + "iat": int(datetime(2026, 1, 1, tzinfo=UTC).timestamp()), + "exp": int(datetime(2026, 1, 2, tzinfo=UTC).timestamp()), + } + monkeypatch.setattr("pequi.use_cases.refresh_token.decode_token", lambda _token: payload) + monkeypatch.setattr("pequi.use_cases.refresh_token.is_token_revoked", _token_is_revoked) + + use_case = RefreshTokenUseCase(FakeUserRepository(existing_user=user)) + + with pytest.raises(UnauthorizedError, match="revoked"): + await use_case.execute(RefreshRequest(refresh_token="refresh")) diff --git a/backend/tests/unit/test_token_blacklist.py b/backend/tests/unit/test_token_blacklist.py new file mode 100644 index 0000000..dc443de --- /dev/null +++ b/backend/tests/unit/test_token_blacklist.py @@ -0,0 +1,40 @@ +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.core import token_blacklist + +pytestmark = pytest.mark.asyncio + + +class FakeRedis: + def __init__(self) -> None: + self.setex_calls: list[tuple[str, int, str]] = [] + + async def setex(self, key: str, ttl: int, value: str) -> None: + self.setex_calls.append((key, ttl, value)) + + +async def test_staging_uses_redis_for_user_revocation_with_ttl(monkeypatch): + fake_redis = FakeRedis() + settings = SimpleNamespace( + ENV="staging", + REDIS_URL="redis://localhost:6379/0", + REFRESH_TOKEN_EXPIRE_DAYS=7, + ACCESS_TOKEN_EXPIRE_MINUTES=30, + ) + monkeypatch.setattr(token_blacklist, "get_settings", lambda: settings) + monkeypatch.setattr(token_blacklist, "_get_redis", lambda: fake_redis) + + await token_blacklist.revoke_user_tokens( + uuid4(), + revoked_at=datetime.now(UTC) - timedelta(seconds=1), + ) + + assert fake_redis.setex_calls + key, ttl, value = fake_redis.setex_calls[0] + assert key.startswith("user_revoked_after:") + assert ttl == 7 * 24 * 60 * 60 + assert value.isdigit() From a4b0e9928f829380300c4eb05f73156592413ea1 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Tue, 2 Jun 2026 15:15:04 -0300 Subject: [PATCH 49/69] PEQ-152: feat(auth) add username to user registration and login (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): add username to user registration and login Adds `username` as a mandatory, unique, public identifier for users. - Model: new `username` column (String(30), unique, indexed) - Migration 103: adds column safely with populated values for existing rows, then enforces NOT NULL + case-insensitive unique index - Schemas: `UserCreate` requires username (3–30 chars, alphanumeric plus `_` and `-`, stored lowercase); `UserResponse` exposes it; `LoginRequest` replaces `email` with `identifier` (email or username) - Repository: `get_by_username` (case-insensitive), `get_by_identifier` - Use cases: `RegisterUserUseCase` validates uniqueness before persist; `LoginUserUseCase` resolves identifier by email or username - Anonymization: username anonymized on account deletion (LGPD) - Export: username included in account data export - Tests: 136 unit tests (zero warnings), new integration test file `test_user_username_flow.py`, E2E tests updated end-to-end - Bruno: `register.bru` and `login.bru` contracts updated Co-authored-by: Cursor * fix(migration): correct down_revision to resolve multiple heads 103_add_username_to_users pointed to 102_unique_constraints, creating a fork alongside 011_create_lgpd_tables. Updated down_revision to 011_create_lgpd_tables so the chain has a single head. Co-authored-by: Cursor --------- Co-authored-by: Rafael Luciano --- .../versions/103_add_username_to_users.py | 68 +++++++ backend/bruno/auth/login.bru | 3 +- backend/bruno/auth/register.bru | 2 + backend/src/pequi/models/user.py | 1 + backend/src/pequi/repositories/user_repo.py | 17 +- backend/src/pequi/schemas/user.py | 25 ++- .../pequi/services/anonymization_service.py | 1 + .../pequi/use_cases/export_account_data.py | 1 + backend/src/pequi/use_cases/login_user.py | 4 +- backend/src/pequi/use_cases/register_user.py | 11 +- backend/tests/e2e/test_auth_flow.py | 180 +++++++++++++++-- backend/tests/e2e/test_patient_me.py | 9 +- .../integration/test_account_deletion.py | 2 + .../test_account_export_and_consents.py | 1 + .../integration/test_adherence_worker.py | 2 + .../tests/integration/test_community_flow.py | 1 + backend/tests/integration/test_dose_flow.py | 2 + .../tests/integration/test_patient_profile.py | 1 + .../tests/integration/test_summary_worker.py | 2 + .../integration/test_user_username_flow.py | 182 ++++++++++++++++++ backend/tests/unit/test_auth_use_cases.py | 94 ++++++++- backend/tests/unit/test_user_schema.py | 95 +++++++++ 22 files changed, 671 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/103_add_username_to_users.py create mode 100644 backend/tests/integration/test_user_username_flow.py create mode 100644 backend/tests/unit/test_user_schema.py diff --git a/backend/alembic/versions/103_add_username_to_users.py b/backend/alembic/versions/103_add_username_to_users.py new file mode 100644 index 0000000..1f408df --- /dev/null +++ b/backend/alembic/versions/103_add_username_to_users.py @@ -0,0 +1,68 @@ +"""add username to users + +Revision ID: 103_add_username_to_users +Revises: 102_unique_constraints +Create Date: 2026-06-02 00:00:00.000000 + +Estratégia de migração para produção: +1. Adiciona coluna `username` como nullable. +2. Popula usuários existentes com um username derivado do prefixo do e-mail + mais os 4 primeiros caracteres hexadecimais do UUID (garantia de unicidade). +3. Adiciona constraint NOT NULL e índice único (case-insensitive via LOWER). +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "103_add_username_to_users" +down_revision: str | None = "011_create_lgpd_tables" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 1. Adiciona coluna nullable para permitir preenchimento seguro dos registros existentes. + op.add_column("users", sa.Column("username", sa.String(30), nullable=True)) + + # 2. Popula username para usuários já cadastrados. + # Formato: _<4 hex chars do UUID> + # Isso garante unicidade mesmo quando múltiplos usuários compartilham o mesmo prefixo. + op.execute( + """ + UPDATE users + SET username = LOWER( + REGEXP_REPLACE( + SPLIT_PART(email, '@', 1), + '[^a-z0-9_\\-]', '_', 'gi' + ) + ) || '_' || SUBSTRING(REPLACE(id::text, '-', ''), 1, 4) + WHERE username IS NULL + """ + ) + + # 3. Garante que nenhum username ficou vazio após a sanitização (edge case). + op.execute( + """ + UPDATE users + SET username = 'user_' || SUBSTRING(REPLACE(id::text, '-', ''), 1, 8) + WHERE username IS NULL OR username = '' OR username = '_' + """ + ) + + # 4. Aplica NOT NULL após o preenchimento. + op.alter_column("users", "username", nullable=False) + + # 5. Cria índice único case-insensitive para garantir unicidade em produção. + op.create_index( + "ix_users_username_lower", + "users", + [sa.text("LOWER(username)")], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_users_username_lower", table_name="users") + op.drop_column("users", "username") diff --git a/backend/bruno/auth/login.bru b/backend/bruno/auth/login.bru index 06bc1d0..8a6d3a0 100644 --- a/backend/bruno/auth/login.bru +++ b/backend/bruno/auth/login.bru @@ -16,7 +16,7 @@ headers { body:json { { - "email": "test@example.com", + "identifier": "test@example.com", "password": "strongpassword123" } } @@ -26,4 +26,5 @@ assert { res.body.access_token: isDefined res.body.refresh_token: isDefined res.body.user: isDefined + res.body.user.username: isDefined } diff --git a/backend/bruno/auth/register.bru b/backend/bruno/auth/register.bru index 7cc7bf4..87a4134 100644 --- a/backend/bruno/auth/register.bru +++ b/backend/bruno/auth/register.bru @@ -17,6 +17,7 @@ headers { body:json { { "email": "paciente@test.com", + "username": "joaopaciente", "password": "secretpassword", "full_name": "João Paciente" } @@ -29,4 +30,5 @@ vars:pre-request { assert { res.status: eq 201 res.body.email: eq "paciente@test.com" + res.body.username: isDefined } diff --git a/backend/src/pequi/models/user.py b/backend/src/pequi/models/user.py index a2e5c8d..bac4a7d 100644 --- a/backend/src/pequi/models/user.py +++ b/backend/src/pequi/models/user.py @@ -12,6 +12,7 @@ class User(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) email = Column(String, unique=True, index=True, nullable=False) + username = Column(String(30), unique=True, index=True, nullable=False) hashed_password = Column(String, nullable=False) full_name = Column(String, nullable=False) role = Column( diff --git a/backend/src/pequi/repositories/user_repo.py b/backend/src/pequi/repositories/user_repo.py index 4ac9603..f5d5089 100644 --- a/backend/src/pequi/repositories/user_repo.py +++ b/backend/src/pequi/repositories/user_repo.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import select +from sqlalchemy import func, select from pequi.models.user import User from pequi.repositories.base import BaseRepository @@ -14,6 +14,21 @@ async def get_by_email(self, email: str) -> User | None: result = await self._session.execute(stmt) return result.scalar_one_or_none() + async def get_by_username(self, username: str) -> User | None: + """Busca por username de forma case-insensitive.""" + stmt = select(self.model).where( + func.lower(self.model.username) == username.lower(), + self.model.deleted_at.is_(None), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_identifier(self, identifier: str) -> User | None: + """Busca por email ou username (case-insensitive).""" + if "@" in identifier: + return await self.get_by_email(identifier) + return await self.get_by_username(identifier) + async def get_by_id(self, id: UUID) -> User | None: stmt = select(self.model).where(self.model.id == id, self.model.deleted_at.is_(None)) result = await self._session.execute(stmt) diff --git a/backend/src/pequi/schemas/user.py b/backend/src/pequi/schemas/user.py index a34b457..5528379 100644 --- a/backend/src/pequi/schemas/user.py +++ b/backend/src/pequi/schemas/user.py @@ -1,7 +1,10 @@ +import re from datetime import datetime from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +_USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]*[a-z0-9]$") class UserCreate(BaseModel): @@ -10,13 +13,26 @@ class UserCreate(BaseModel): model_config = ConfigDict(extra="forbid") email: EmailStr + username: str = Field(..., min_length=3, max_length=30) password: str = Field(..., min_length=8, max_length=64) full_name: str = Field(..., min_length=2, max_length=100) + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + normalized = v.lower() + if not _USERNAME_RE.match(normalized): + raise ValueError( + "username must contain only letters, digits, underscores or hyphens, " + "and must start and end with a letter or digit" + ) + return normalized + class UserResponse(BaseModel): id: UUID email: EmailStr + username: str full_name: str role: str is_active: bool @@ -40,5 +56,10 @@ class RefreshRequest(BaseModel): class LoginRequest(BaseModel): - email: EmailStr + """Login por email ou username. + + ``identifier`` pode ser o endereço de e-mail ou o username do usuário. + """ + + identifier: str = Field(..., min_length=1, max_length=254) password: str diff --git a/backend/src/pequi/services/anonymization_service.py b/backend/src/pequi/services/anonymization_service.py index d7a2692..183063a 100644 --- a/backend/src/pequi/services/anonymization_service.py +++ b/backend/src/pequi/services/anonymization_service.py @@ -27,6 +27,7 @@ async def anonymize_account( digest = sha256(str(user.id).encode("utf-8")).hexdigest() user.email = f"deleted:{digest}@anonymous.local" + user.username = f"deleted{digest[:16]}" user.full_name = "Usuario Removido" user.hashed_password = hash_password(f"deleted:{uuid4()}:{digest}") user.is_active = False diff --git a/backend/src/pequi/use_cases/export_account_data.py b/backend/src/pequi/use_cases/export_account_data.py index 8a41999..d031d66 100644 --- a/backend/src/pequi/use_cases/export_account_data.py +++ b/backend/src/pequi/use_cases/export_account_data.py @@ -43,6 +43,7 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict include=[ "id", "email", + "username", "full_name", "role", "is_active", diff --git a/backend/src/pequi/use_cases/login_user.py b/backend/src/pequi/use_cases/login_user.py index 7a9db73..9f496ed 100644 --- a/backend/src/pequi/use_cases/login_user.py +++ b/backend/src/pequi/use_cases/login_user.py @@ -12,10 +12,10 @@ def __init__(self, user_repo: UserRepository): self.user_repo = user_repo async def execute(self, data: LoginRequest) -> AuthResponse: - user = await self.user_repo.get_by_email(data.email) + user = await self.user_repo.get_by_identifier(data.identifier) if not user or not verify_password(data.password, user.hashed_password): - raise UnauthorizedError("Invalid email or password") + raise UnauthorizedError("Invalid credentials") if not user.is_active: raise UnauthorizedError("User is inactive") diff --git a/backend/src/pequi/use_cases/register_user.py b/backend/src/pequi/use_cases/register_user.py index 881df5d..199568c 100644 --- a/backend/src/pequi/use_cases/register_user.py +++ b/backend/src/pequi/use_cases/register_user.py @@ -1,25 +1,32 @@ from pequi.core.auth import hash_password from pequi.core.exceptions import ConflictError +from pequi.core.logging import get_logger from pequi.models.user import User from pequi.repositories.user_repo import UserRepository from pequi.schemas.user import UserCreate, UserResponse +logger = get_logger(__name__) + class RegisterUserUseCase: def __init__(self, user_repo: UserRepository): self.user_repo = user_repo async def execute(self, data: UserCreate) -> UserResponse: - existing_user = await self.user_repo.get_by_email(data.email) - if existing_user: + if await self.user_repo.get_by_email(data.email): raise ConflictError("Unable to register with these credentials.") + if await self.user_repo.get_by_username(data.username): + raise ConflictError("Username already taken.") + hashed = hash_password(data.password) user = User( email=data.email, + username=data.username, hashed_password=hashed, full_name=data.full_name, role="patient", ) user = await self.user_repo.add(user) + logger.info("user.registered", user_id=str(user.id), username=user.username) return UserResponse.model_validate(user) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 7673256..90b4474 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -3,22 +3,50 @@ pytestmark = pytest.mark.asyncio +_REGISTER_PAYLOAD = { + "email": "test@example.com", + "username": "testuser", + "password": "strongpassword123", + "full_name": "Test User", +} + async def test_register_user_success(create_tables, async_client: AsyncClient): + response = await async_client.post("/v1/auth/register", json=_REGISTER_PAYLOAD) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == "test@example.com" + assert data["username"] == "testuser" + assert data["role"] == "patient" + assert "password" not in data + assert "hashed_password" not in data + + +async def test_register_username_is_returned_normalized(create_tables, async_client: AsyncClient): response = await async_client.post( "/v1/auth/register", json={ - "email": "test@example.com", + "email": "norm@example.com", + "username": "NormUser", "password": "strongpassword123", - "full_name": "Test User", + "full_name": "Norm User", }, ) assert response.status_code == 201 - data = response.json() - assert data["email"] == "test@example.com" - assert data["role"] == "patient" - assert "password" not in data - assert "hashed_password" not in data + assert response.json()["username"] == "normuser" + + +async def test_register_rejects_missing_username(create_tables, async_client: AsyncClient): + response = await async_client.post( + "/v1/auth/register", + json={ + "email": "nousername@example.com", + "password": "strongpassword123", + "full_name": "No Username", + }, + ) + assert response.status_code == 422 async def test_register_rejects_role_in_body(create_tables, async_client: AsyncClient): @@ -26,6 +54,7 @@ async def test_register_rejects_role_in_body(create_tables, async_client: AsyncC "/v1/auth/register", json={ "email": "admin@example.com", + "username": "badactor", "password": "strongpassword123", "full_name": "Bad Actor", "role": "admin", @@ -39,6 +68,7 @@ async def test_register_user_duplicate_email(create_tables, async_client: AsyncC "/v1/auth/register", json={ "email": "duplicate@example.com", + "username": "dupuser1", "password": "strongpassword123", "full_name": "Test User", }, @@ -47,6 +77,7 @@ async def test_register_user_duplicate_email(create_tables, async_client: AsyncC "/v1/auth/register", json={ "email": "duplicate@example.com", + "username": "dupuser2", "password": "anotherpassword", "full_name": "Another User", }, @@ -55,11 +86,59 @@ async def test_register_user_duplicate_email(create_tables, async_client: AsyncC assert response.json()["detail"] == "Unable to register with these credentials." -async def test_login_user_success(create_tables, async_client: AsyncClient): +async def test_register_user_duplicate_username(create_tables, async_client: AsyncClient): + await async_client.post( + "/v1/auth/register", + json={ + "email": "first@example.com", + "username": "sharedusername", + "password": "strongpassword123", + "full_name": "First User", + }, + ) + response = await async_client.post( + "/v1/auth/register", + json={ + "email": "second@example.com", + "username": "sharedusername", + "password": "anotherpassword", + "full_name": "Second User", + }, + ) + assert response.status_code == 409 + assert response.json()["detail"] == "Username already taken." + + +async def test_register_duplicate_username_case_insensitive( + create_tables, async_client: AsyncClient +): + await async_client.post( + "/v1/auth/register", + json={ + "email": "ci1@example.com", + "username": "casetest", + "password": "strongpassword123", + "full_name": "CI User 1", + }, + ) + response = await async_client.post( + "/v1/auth/register", + json={ + "email": "ci2@example.com", + "username": "CaseTest", + "password": "strongpassword123", + "full_name": "CI User 2", + }, + ) + assert response.status_code == 409 + + +async def test_login_by_email_success(create_tables, async_client: AsyncClient): await async_client.post( "/v1/auth/register", json={ "email": "login@example.com", + "username": "loginuser", "password": "loginpassword123", "full_name": "Login User", }, @@ -67,7 +146,7 @@ async def test_login_user_success(create_tables, async_client: AsyncClient): response = await async_client.post( "/v1/auth/login", - json={"email": "login@example.com", "password": "loginpassword123"}, + json={"identifier": "login@example.com", "password": "loginpassword123"}, ) assert response.status_code == 200 @@ -75,8 +154,47 @@ async def test_login_user_success(create_tables, async_client: AsyncClient): assert "access_token" in data assert "refresh_token" in data assert "expires_in" in data - assert "user" in data assert data["user"]["email"] == "login@example.com" + assert data["user"]["username"] == "loginuser" + + +async def test_login_by_username_success(create_tables, async_client: AsyncClient): + await async_client.post( + "/v1/auth/register", + json={ + "email": "byusername@example.com", + "username": "myusername", + "password": "loginpassword123", + "full_name": "By Username", + }, + ) + + response = await async_client.post( + "/v1/auth/login", + json={"identifier": "myusername", "password": "loginpassword123"}, + ) + + assert response.status_code == 200 + assert response.json()["user"]["username"] == "myusername" + + +async def test_login_by_username_case_insensitive(create_tables, async_client: AsyncClient): + await async_client.post( + "/v1/auth/register", + json={ + "email": "caselogin@example.com", + "username": "caseloginuser", + "password": "loginpassword123", + "full_name": "Case Login", + }, + ) + + response = await async_client.post( + "/v1/auth/login", + json={"identifier": "CaseLoginUser", "password": "loginpassword123"}, + ) + + assert response.status_code == 200 async def test_login_user_invalid_credentials(create_tables, async_client: AsyncClient): @@ -84,6 +202,7 @@ async def test_login_user_invalid_credentials(create_tables, async_client: Async "/v1/auth/register", json={ "email": "invalid@example.com", + "username": "invaliduser", "password": "correctpassword", "full_name": "Invalid User", }, @@ -91,13 +210,13 @@ async def test_login_user_invalid_credentials(create_tables, async_client: Async response = await async_client.post( "/v1/auth/login", - json={"email": "invalid@example.com", "password": "wrongpassword"}, + json={"identifier": "invalid@example.com", "password": "wrongpassword"}, ) assert response.status_code == 401 response = await async_client.post( "/v1/auth/login", - json={"email": "notfound@example.com", "password": "correctpassword"}, + json={"identifier": "notfound@example.com", "password": "correctpassword"}, ) assert response.status_code == 401 @@ -107,6 +226,7 @@ async def test_refresh_token_success(create_tables, async_client: AsyncClient): "/v1/auth/register", json={ "email": "refresh@example.com", + "username": "refreshuser", "password": "refreshpassword", "full_name": "Refresh User", }, @@ -114,7 +234,7 @@ async def test_refresh_token_success(create_tables, async_client: AsyncClient): login_response = await async_client.post( "/v1/auth/login", - json={"email": "refresh@example.com", "password": "refreshpassword"}, + json={"identifier": "refresh@example.com", "password": "refreshpassword"}, ) refresh_token = login_response.json()["refresh_token"] @@ -136,3 +256,37 @@ async def test_refresh_token_invalid(create_tables, async_client: AsyncClient): json={"refresh_token": "invalid_or_fake_token"}, ) assert response.status_code == 401 + + +async def test_full_journey_register_login_profile(create_tables, async_client: AsyncClient): + """Jornada E2E: cadastro → login por email → login por username → token refresh.""" + reg = await async_client.post( + "/v1/auth/register", + json={ + "email": "journey@example.com", + "username": "journeyuser", + "password": "journeypass123", + "full_name": "Journey User", + }, + ) + assert reg.status_code == 201 + assert reg.json()["username"] == "journeyuser" + + by_email = await async_client.post( + "/v1/auth/login", + json={"identifier": "journey@example.com", "password": "journeypass123"}, + ) + assert by_email.status_code == 200 + assert by_email.json()["user"]["username"] == "journeyuser" + + by_username = await async_client.post( + "/v1/auth/login", + json={"identifier": "journeyuser", "password": "journeypass123"}, + ) + assert by_username.status_code == 200 + + refresh_resp = await async_client.post( + "/v1/auth/refresh", + json={"refresh_token": by_email.json()["refresh_token"]}, + ) + assert refresh_resp.status_code == 200 diff --git a/backend/tests/e2e/test_patient_me.py b/backend/tests/e2e/test_patient_me.py index 2ee96a8..bec2fdb 100644 --- a/backend/tests/e2e/test_patient_me.py +++ b/backend/tests/e2e/test_patient_me.py @@ -23,6 +23,7 @@ async def test_get_me_wrong_role( # Create an admin user directly in DB admin_user = User( email="admin@example.com", + username="adminuser", hashed_password=hash_password("adminpassword"), full_name="Admin User", role="admin", @@ -33,7 +34,7 @@ async def test_get_me_wrong_role( # Log in login_response = await async_client.post( "/v1/auth/login", - json={"email": "admin@example.com", "password": "adminpassword"}, + json={"identifier": "admin@example.com", "password": "adminpassword"}, ) assert login_response.status_code == 200 token = login_response.json()["access_token"] @@ -50,6 +51,7 @@ async def test_get_me_success(create_tables, async_client: AsyncClient, db_sessi # Create a patient user directly in DB patient_user = User( email="patient@example.com", + username="patientuser", hashed_password=hash_password("patientpassword"), full_name="Patient User", role="patient", @@ -79,7 +81,7 @@ async def test_get_me_success(create_tables, async_client: AsyncClient, db_sessi # Log in login_response = await async_client.post( "/v1/auth/login", - json={"email": "patient@example.com", "password": "patientpassword"}, + json={"identifier": "patient@example.com", "password": "patientpassword"}, ) assert login_response.status_code == 200 token = login_response.json()["access_token"] @@ -98,6 +100,7 @@ async def test_patch_me_success(create_tables, async_client: AsyncClient, db_ses # Create a patient user directly in DB patient_user = User( email="patient2@example.com", + username="patientuser2", hashed_password=hash_password("patientpassword"), full_name="Patient User 2", role="patient", @@ -127,7 +130,7 @@ async def test_patch_me_success(create_tables, async_client: AsyncClient, db_ses # Log in login_response = await async_client.post( "/v1/auth/login", - json={"email": "patient2@example.com", "password": "patientpassword"}, + json={"identifier": "patient2@example.com", "password": "patientpassword"}, ) token = login_response.json()["access_token"] diff --git a/backend/tests/integration/test_account_deletion.py b/backend/tests/integration/test_account_deletion.py index f1bb144..251725b 100644 --- a/backend/tests/integration/test_account_deletion.py +++ b/backend/tests/integration/test_account_deletion.py @@ -32,6 +32,7 @@ async def _create_patient(db_session: AsyncSession) -> tuple[User, PatientProfil unit = HealthUnit(name="UBS", city="Cidade", state="SP", cnes=str(uuid4())[:12]) user = User( email=f"{uuid4()}@example.com", + username=f"patient{uuid4().hex[:8]}", hashed_password="$2b$12$YyAjsZqFgKMm.yK7hyRre.eD0mgoTb1foL3.zIg0SBsDzz8qyPZ7G", full_name="Paciente Teste", role="patient", @@ -58,6 +59,7 @@ async def _create_patient(db_session: AsyncSession) -> tuple[User, PatientProfil async def _create_professional(db_session: AsyncSession, unit_id) -> HealthProfessional: user = User( email=f"prof-{uuid4()}@example.com", + username=f"prof{uuid4().hex[:8]}", hashed_password="hash", full_name="Profissional", role="health_professional", diff --git a/backend/tests/integration/test_account_export_and_consents.py b/backend/tests/integration/test_account_export_and_consents.py index 8a8d2d2..e746cb7 100644 --- a/backend/tests/integration/test_account_export_and_consents.py +++ b/backend/tests/integration/test_account_export_and_consents.py @@ -24,6 +24,7 @@ async def _create_patient(db_session: AsyncSession) -> tuple[User, PatientProfil unit = HealthUnit(name="UBS", city="Cidade", state="SP", cnes=str(uuid4())[:12]) user = User( email=f"{uuid4()}@example.com", + username=f"patient{uuid4().hex[:8]}", hashed_password="hash", full_name="Paciente Teste", role="patient", diff --git a/backend/tests/integration/test_adherence_worker.py b/backend/tests/integration/test_adherence_worker.py index 0a53677..e0378d1 100644 --- a/backend/tests/integration/test_adherence_worker.py +++ b/backend/tests/integration/test_adherence_worker.py @@ -38,6 +38,7 @@ async def _create_patient_with_treatment( user = User( id=user_id, email=f"test{user_id}@example.com", + username=f"patient{str(user_id).replace('-', '')[:8]}", hashed_password="hashed", full_name="Test User", role="patient", @@ -69,6 +70,7 @@ async def _create_patient_with_treatment( professional_user = User( id=professional_user_id, email=f"prof{professional_user_id}@example.com", + username=f"prof{str(professional_user_id).replace('-', '')[:8]}", hashed_password="hashed", full_name="Test Professional", role="health_professional", diff --git a/backend/tests/integration/test_community_flow.py b/backend/tests/integration/test_community_flow.py index de26ec4..5a947f5 100644 --- a/backend/tests/integration/test_community_flow.py +++ b/backend/tests/integration/test_community_flow.py @@ -30,6 +30,7 @@ async def _create_admin_user(session, email: str = "admin@test.com"): user = User( id=uuid4(), email=email, + username=email.split("@")[0].replace(".", "_").replace("-", "_")[:30], hashed_password="hashed", full_name="Admin User", role="admin", diff --git a/backend/tests/integration/test_dose_flow.py b/backend/tests/integration/test_dose_flow.py index 79a9023..63d4536 100644 --- a/backend/tests/integration/test_dose_flow.py +++ b/backend/tests/integration/test_dose_flow.py @@ -37,9 +37,11 @@ async def _create_health_unit(session, *, name: str = "UBS Central") -> HealthUn async def _create_user(session, *, email: str, role: str) -> User: + username = email.split("@")[0].replace(".", "_").replace("-", "_")[:30] user = User( id=uuid4(), email=email, + username=username, hashed_password="$2b$12$placeholder", full_name="Test User", role=role, diff --git a/backend/tests/integration/test_patient_profile.py b/backend/tests/integration/test_patient_profile.py index 442265d..4cbacec 100644 --- a/backend/tests/integration/test_patient_profile.py +++ b/backend/tests/integration/test_patient_profile.py @@ -22,6 +22,7 @@ async def test_get_patient_profile(create_tables, db_session): user = User( id=user_id, email="patient_test@example.com", + username="patienttest", hashed_password="hash", full_name="Patient Test", role="patient", diff --git a/backend/tests/integration/test_summary_worker.py b/backend/tests/integration/test_summary_worker.py index b6132d8..6889214 100644 --- a/backend/tests/integration/test_summary_worker.py +++ b/backend/tests/integration/test_summary_worker.py @@ -35,6 +35,7 @@ async def test_summary_job_processes_active_patients(db_session: AsyncSession, m user = User( id=user_id, email=f"test{user_id}@example.com", + username=f"patient{str(user_id).replace('-', '')[:8]}", hashed_password="hashed", full_name="Test User", role="patient", @@ -66,6 +67,7 @@ async def test_summary_job_processes_active_patients(db_session: AsyncSession, m professional_user = User( id=professional_user_id, email=f"prof{professional_user_id}@example.com", + username=f"prof{str(professional_user_id).replace('-', '')[:8]}", hashed_password="hashed", full_name="Test Professional", role="health_professional", diff --git a/backend/tests/integration/test_user_username_flow.py b/backend/tests/integration/test_user_username_flow.py new file mode 100644 index 0000000..8783b26 --- /dev/null +++ b/backend/tests/integration/test_user_username_flow.py @@ -0,0 +1,182 @@ +"""Testes de integração — username no fluxo de cadastro e login. + +Exercita UserRepository, RegisterUserUseCase e LoginUserUseCase diretamente +contra o banco de dados real (PostgreSQL), sem HTTP. +""" + +import pytest + +from pequi.core.exceptions import ConflictError, UnauthorizedError +from pequi.repositories.user_repo import UserRepository +from pequi.schemas.user import LoginRequest, UserCreate +from pequi.use_cases.login_user import LoginUserUseCase +from pequi.use_cases.register_user import RegisterUserUseCase + +pytestmark = pytest.mark.asyncio + + +async def test_register_persists_username(db_session): + repo = UserRepository(db_session) + use_case = RegisterUserUseCase(repo) + + result = await use_case.execute( + UserCreate( + email="persist@example.com", + username="persistuser", + password="strongpassword123", + full_name="Persist User", + ) + ) + + assert result.username == "persistuser" + + fetched = await repo.get_by_username("persistuser") + assert fetched is not None + assert fetched.email == "persist@example.com" + + +async def test_register_username_stored_lowercase(db_session): + repo = UserRepository(db_session) + use_case = RegisterUserUseCase(repo) + + result = await use_case.execute( + UserCreate( + email="lower@example.com", + username="UpperCaseUser", + password="strongpassword123", + full_name="Upper Case", + ) + ) + + assert result.username == "uppercaseuser" + + +async def test_get_by_username_case_insensitive(db_session): + repo = UserRepository(db_session) + use_case = RegisterUserUseCase(repo) + + await use_case.execute( + UserCreate( + email="ci@example.com", + username="myciuser", + password="strongpassword123", + full_name="CI User", + ) + ) + + assert await repo.get_by_username("myciuser") is not None + assert await repo.get_by_username("MyCIUser") is not None + assert await repo.get_by_username("MYCIUSER") is not None + + +async def test_register_rejects_duplicate_username(db_session): + repo = UserRepository(db_session) + use_case = RegisterUserUseCase(repo) + + await use_case.execute( + UserCreate( + email="first@example.com", + username="duplicated", + password="strongpassword123", + full_name="First User", + ) + ) + + with pytest.raises(ConflictError, match="Username already taken"): + await use_case.execute( + UserCreate( + email="second@example.com", + username="duplicated", + password="strongpassword123", + full_name="Second User", + ) + ) + + +async def test_register_rejects_duplicate_username_case_insensitive(db_session): + repo = UserRepository(db_session) + use_case = RegisterUserUseCase(repo) + + await use_case.execute( + UserCreate( + email="orig@example.com", + username="uniquename", + password="strongpassword123", + full_name="Original User", + ) + ) + + with pytest.raises(ConflictError, match="Username already taken"): + await use_case.execute( + UserCreate( + email="other@example.com", + username="UniqueName", + password="strongpassword123", + full_name="Other User", + ) + ) + + +async def test_login_by_email(db_session): + repo = UserRepository(db_session) + await RegisterUserUseCase(repo).execute( + UserCreate( + email="emaillogin@example.com", + username="emailloginuser", + password="mypassword123", + full_name="Email Login", + ) + ) + + result = await LoginUserUseCase(repo).execute( + LoginRequest(identifier="emaillogin@example.com", password="mypassword123") + ) + + assert result.user.username == "emailloginuser" + assert result.access_token + + +async def test_login_by_username(db_session): + repo = UserRepository(db_session) + await RegisterUserUseCase(repo).execute( + UserCreate( + email="userlogin@example.com", + username="userloginuser", + password="mypassword123", + full_name="User Login", + ) + ) + + result = await LoginUserUseCase(repo).execute( + LoginRequest(identifier="userloginuser", password="mypassword123") + ) + + assert result.user.email == "userlogin@example.com" + assert result.access_token + + +async def test_login_by_username_case_insensitive(db_session): + repo = UserRepository(db_session) + await RegisterUserUseCase(repo).execute( + UserCreate( + email="cilogin@example.com", + username="ciloginuser", + password="mypassword123", + full_name="CI Login", + ) + ) + + result = await LoginUserUseCase(repo).execute( + LoginRequest(identifier="CILoginUser", password="mypassword123") + ) + + assert result.user.username == "ciloginuser" + + +async def test_login_rejects_unknown_identifier(db_session): + repo = UserRepository(db_session) + + with pytest.raises(UnauthorizedError): + await LoginUserUseCase(repo).execute( + LoginRequest(identifier="ghost@example.com", password="anypassword") + ) diff --git a/backend/tests/unit/test_auth_use_cases.py b/backend/tests/unit/test_auth_use_cases.py index 72ae9ff..59f308b 100644 --- a/backend/tests/unit/test_auth_use_cases.py +++ b/backend/tests/unit/test_auth_use_cases.py @@ -17,6 +17,7 @@ def _user(**overrides): base = { "id": uuid4(), "email": "user@example.com", + "username": "testuser", "hashed_password": "hashed-password", "full_name": "Test User", "role": "patient", @@ -39,6 +40,16 @@ async def get_by_email(self, email): return None return self.existing_user + async def get_by_username(self, username): + if self.existing_user is None or self.existing_user.username.lower() != username.lower(): + return None + return self.existing_user + + async def get_by_identifier(self, identifier): + if "@" in identifier: + return await self.get_by_email(identifier) + return await self.get_by_username(identifier) + async def add(self, user): self.added_user = user user.id = uuid4() @@ -78,6 +89,11 @@ async def _token_is_revoked(_payload): return True +# --------------------------------------------------------------------------- +# RegisterUserUseCase +# --------------------------------------------------------------------------- + + async def test_register_user_hashes_password_and_always_creates_patient(monkeypatch): repo = FakeUserRepository() monkeypatch.setattr("pequi.use_cases.register_user.hash_password", _hashed_password) @@ -86,12 +102,14 @@ async def test_register_user_hashes_password_and_always_creates_patient(monkeypa result = await use_case.execute( UserCreate( email="new@example.com", + username="newpatient", password="strongpassword123", full_name="New Patient", ) ) assert result.email == "new@example.com" + assert result.username == "newpatient" assert result.role == "patient" assert repo.added_user is not None assert repo.added_user.hashed_password == "hashed" @@ -99,21 +117,58 @@ async def test_register_user_hashes_password_and_always_creates_patient(monkeypa async def test_register_user_rejects_duplicate_email(): - repo = FakeUserRepository(existing_user=_user(email="taken@example.com")) + repo = FakeUserRepository(existing_user=_user(email="taken@example.com", username="taken")) use_case = RegisterUserUseCase(repo) with pytest.raises(ConflictError): await use_case.execute( UserCreate( email="taken@example.com", + username="otheruser", password="strongpassword123", full_name="Taken User", ) ) -async def test_login_user_returns_tokens_for_active_user(monkeypatch): - user = _user(email="login@example.com") +async def test_register_user_rejects_duplicate_username(): + repo = FakeUserRepository(existing_user=_user(email="other@example.com", username="takenuser")) + use_case = RegisterUserUseCase(repo) + + with pytest.raises(ConflictError, match="Username already taken"): + await use_case.execute( + UserCreate( + email="new@example.com", + username="takenuser", + password="strongpassword123", + full_name="New User", + ) + ) + + +async def test_register_user_username_case_insensitive_conflict(): + """Username 'TakenUser' deve conflitar com 'takenuser' já cadastrado.""" + repo = FakeUserRepository(existing_user=_user(email="other@example.com", username="takenuser")) + use_case = RegisterUserUseCase(repo) + + with pytest.raises(ConflictError, match="Username already taken"): + await use_case.execute( + UserCreate( + email="new@example.com", + username="TakenUser", + password="strongpassword123", + full_name="New User", + ) + ) + + +# --------------------------------------------------------------------------- +# LoginUserUseCase +# --------------------------------------------------------------------------- + + +async def test_login_user_by_email_returns_tokens(monkeypatch): + user = _user(email="login@example.com", username="loginuser") repo = FakeUserRepository(existing_user=user) monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_matches) monkeypatch.setattr("pequi.use_cases.login_user.create_access_token", _access_token) @@ -121,7 +176,24 @@ async def test_login_user_returns_tokens_for_active_user(monkeypatch): use_case = LoginUserUseCase(repo) result = await use_case.execute( - LoginRequest(email="login@example.com", password="strongpassword123") + LoginRequest(identifier="login@example.com", password="strongpassword123") + ) + + assert result.access_token == "access" + assert result.refresh_token == "refresh" + assert result.user.id == user.id + + +async def test_login_user_by_username_returns_tokens(monkeypatch): + user = _user(email="login@example.com", username="loginuser") + repo = FakeUserRepository(existing_user=user) + monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_matches) + monkeypatch.setattr("pequi.use_cases.login_user.create_access_token", _access_token) + monkeypatch.setattr("pequi.use_cases.login_user.create_refresh_token", _refresh_token) + use_case = LoginUserUseCase(repo) + + result = await use_case.execute( + LoginRequest(identifier="loginuser", password="strongpassword123") ) assert result.access_token == "access" @@ -134,27 +206,31 @@ async def test_login_user_rejects_missing_user(): with pytest.raises(UnauthorizedError): await use_case.execute( - LoginRequest(email="missing@example.com", password="strongpassword123") + LoginRequest(identifier="missing@example.com", password="strongpassword123") ) async def test_login_user_rejects_wrong_password(monkeypatch): - repo = FakeUserRepository(existing_user=_user(email="login@example.com")) + repo = FakeUserRepository(existing_user=_user(email="login@example.com", username="loginuser")) monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_does_not_match) use_case = LoginUserUseCase(repo) with pytest.raises(UnauthorizedError): - await use_case.execute(LoginRequest(email="login@example.com", password="wrongpassword")) + await use_case.execute( + LoginRequest(identifier="login@example.com", password="wrongpassword") + ) async def test_login_user_rejects_inactive_user(monkeypatch): - repo = FakeUserRepository(existing_user=_user(email="inactive@example.com", is_active=False)) + repo = FakeUserRepository( + existing_user=_user(email="inactive@example.com", username="inactiveuser", is_active=False) + ) monkeypatch.setattr("pequi.use_cases.login_user.verify_password", _password_matches) use_case = LoginUserUseCase(repo) with pytest.raises(UnauthorizedError): await use_case.execute( - LoginRequest(email="inactive@example.com", password="strongpassword123") + LoginRequest(identifier="inactive@example.com", password="strongpassword123") ) diff --git a/backend/tests/unit/test_user_schema.py b/backend/tests/unit/test_user_schema.py new file mode 100644 index 0000000..87a33d9 --- /dev/null +++ b/backend/tests/unit/test_user_schema.py @@ -0,0 +1,95 @@ +"""Testes unitários de validação Pydantic — schemas de usuário.""" + +import pytest + +from pequi.schemas.user import UserCreate + + +def test_username_is_normalized_to_lowercase(): + data = UserCreate( + email="u@example.com", + username="JoaoSilva", + password="strongpass123", + full_name="João Silva", + ) + assert data.username == "joaosilva" + + +def test_username_accepts_valid_characters(): + data = UserCreate( + email="u@example.com", + username="joao_silva-42", + password="strongpass123", + full_name="João Silva", + ) + assert data.username == "joao_silva-42" + + +def test_username_rejects_spaces(): + with pytest.raises(ValueError, match="username"): + UserCreate( + email="u@example.com", + username="joao silva", + password="strongpass123", + full_name="João Silva", + ) + + +def test_username_rejects_leading_hyphen(): + with pytest.raises(ValueError, match="username"): + UserCreate( + email="u@example.com", + username="-joao", + password="strongpass123", + full_name="João Silva", + ) + + +def test_username_rejects_trailing_hyphen(): + with pytest.raises(ValueError, match="username"): + UserCreate( + email="u@example.com", + username="joao-", + password="strongpass123", + full_name="João Silva", + ) + + +def test_username_rejects_too_short(): + with pytest.raises(ValueError): + UserCreate( + email="u@example.com", + username="ab", + password="strongpass123", + full_name="João Silva", + ) + + +def test_username_rejects_too_long(): + with pytest.raises(ValueError): + UserCreate( + email="u@example.com", + username="a" * 31, + password="strongpass123", + full_name="João Silva", + ) + + +def test_username_minimum_length_3(): + data = UserCreate( + email="u@example.com", + username="abc", + password="strongpass123", + full_name="João Silva", + ) + assert data.username == "abc" + + +def test_username_rejects_special_chars(): + with pytest.raises(ValueError, match="username"): + UserCreate( + email="u@example.com", + username="joao@silva", + password="strongpass123", + full_name="João Silva", + ) From 8a50524354ea893b9393149ac0412acc69c766aa Mon Sep 17 00:00:00 2001 From: Leila Biggi <87096464+lawtherea@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:53:20 -0300 Subject: [PATCH 50/69] [PEQ-54]: education tab already connected to back (#50) * [PEQ-54]: education tab already connected to back * fix: updated register flow to use username and show on header * fix: username cant have space --- backend/src/pequi/repositories/user_repo.py | 9 ++ backend/src/pequi/routers/auth.py | 25 ++- backend/src/pequi/schemas/user.py | 27 +++- backend/src/pequi/use_cases/login_user.py | 4 +- backend/src/pequi/use_cases/register_user.py | 4 +- .../src/pequi/use_cases/update_username.py | 32 ++++ backend/tests/e2e/test_auth_flow.py | 4 +- .../integration/test_user_username_flow.py | 4 +- backend/tests/unit/test_auth_use_cases.py | 4 +- backend/tests/unit/test_user_schema.py | 39 +++-- frontend/src/app/app.routes.ts | 2 + .../app/components/app-header/app-header.html | 28 ++-- .../app/components/app-header/app-header.ts | 11 +- frontend/src/app/core/api-error.utils.ts | 101 ++++++++++++ .../auth/services/auth-service.spec.ts | 8 +- .../features/auth/services/auth-service.ts | 76 +++++++-- .../src/app/features/auth/username.utils.ts | 34 ++++ .../education-article-card.html | 52 +++++++ .../education-article-card.spec.ts | 57 +++++++ .../education-article-card.ts | 33 ++++ .../education-filter-tags.html | 24 +++ .../education-filter-tags.spec.ts | 41 +++++ .../education-filter-tags.ts | 23 +++ .../education-article-page.html | 90 +++++++++++ .../education-article-page.spec.ts | 79 ++++++++++ .../education-article-page.ts | 77 +++++++++ .../src/app/features/education/education.html | 61 +++++++- .../app/features/education/education.spec.ts | 147 +++++++++++++++++- .../src/app/features/education/education.ts | 106 ++++++++++++- .../education/models/article.models.ts | 43 +++++ .../services/articles.service.spec.ts | 75 +++++++++ .../education/services/articles.service.ts | 34 ++++ frontend/src/app/features/login/login.html | 9 +- frontend/src/app/features/login/login.spec.ts | 50 +++--- frontend/src/app/features/login/login.ts | 11 +- .../features/medication/medication.spec.ts | 4 + .../profile-edit-personal.html | 12 +- .../profile-edit-personal.spec.ts | 17 +- .../profile-edit-personal.ts | 3 +- .../profile-edit-username.html | 90 +++++++++++ .../profile-edit-username.ts | 60 +++++++ .../src/app/features/profile/profile.html | 39 ++++- .../src/app/features/profile/profile.spec.ts | 40 +++-- frontend/src/app/features/profile/profile.ts | 35 ++++- .../services/patient-profile.service.spec.ts | 35 +++-- .../services/patient-profile.service.ts | 32 ++-- .../src/app/features/register/register.html | 15 +- .../app/features/register/register.spec.ts | 45 ++++-- .../src/app/features/register/register.ts | 23 ++- .../app-shell-component.html | 1 - .../app-shell-component.ts | 4 +- frontend/src/index.html | 4 +- 52 files changed, 1698 insertions(+), 185 deletions(-) create mode 100644 backend/src/pequi/use_cases/update_username.py create mode 100644 frontend/src/app/core/api-error.utils.ts create mode 100644 frontend/src/app/features/auth/username.utils.ts create mode 100644 frontend/src/app/features/education/components/education-article-card/education-article-card.html create mode 100644 frontend/src/app/features/education/components/education-article-card/education-article-card.spec.ts create mode 100644 frontend/src/app/features/education/components/education-article-card/education-article-card.ts create mode 100644 frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.html create mode 100644 frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.spec.ts create mode 100644 frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.ts create mode 100644 frontend/src/app/features/education/education-article-page/education-article-page.html create mode 100644 frontend/src/app/features/education/education-article-page/education-article-page.spec.ts create mode 100644 frontend/src/app/features/education/education-article-page/education-article-page.ts create mode 100644 frontend/src/app/features/education/models/article.models.ts create mode 100644 frontend/src/app/features/education/services/articles.service.spec.ts create mode 100644 frontend/src/app/features/education/services/articles.service.ts create mode 100644 frontend/src/app/features/profile/components/profile-edit-username/profile-edit-username.html create mode 100644 frontend/src/app/features/profile/components/profile-edit-username/profile-edit-username.ts diff --git a/backend/src/pequi/repositories/user_repo.py b/backend/src/pequi/repositories/user_repo.py index f5d5089..221aab2 100644 --- a/backend/src/pequi/repositories/user_repo.py +++ b/backend/src/pequi/repositories/user_repo.py @@ -33,3 +33,12 @@ async def get_by_id(self, id: UUID) -> User | None: stmt = select(self.model).where(self.model.id == id, self.model.deleted_at.is_(None)) result = await self._session.execute(stmt) return result.scalar_one_or_none() + + async def update_username(self, user_id: UUID, username: str) -> User | None: + user = await self.get_by_id(user_id) + if not user: + return None + user.username = username + await self._session.flush() + await self._session.refresh(user) + return user diff --git a/backend/src/pequi/routers/auth.py b/backend/src/pequi/routers/auth.py index 23be1ea..e4df630 100644 --- a/backend/src/pequi/routers/auth.py +++ b/backend/src/pequi/routers/auth.py @@ -6,10 +6,18 @@ from pequi.core.dependencies import get_current_user, get_db from pequi.core.rate_limit import limiter from pequi.repositories.user_repo import UserRepository -from pequi.schemas.user import AuthResponse, LoginRequest, RefreshRequest, UserCreate, UserResponse +from pequi.schemas.user import ( + AuthResponse, + LoginRequest, + RefreshRequest, + UserCreate, + UserResponse, + UsernameUpdate, +) from pequi.use_cases.login_user import LoginUserUseCase from pequi.use_cases.refresh_token import RefreshTokenUseCase from pequi.use_cases.register_user import RegisterUserUseCase +from pequi.use_cases.update_username import UpdateUsernameUseCase router = APIRouter() @@ -26,6 +34,10 @@ def get_refresh_use_case(session: AsyncSession = Depends(get_db)) -> RefreshToke return RefreshTokenUseCase(UserRepository(session)) +def get_update_username_use_case(session: AsyncSession = Depends(get_db)) -> UpdateUsernameUseCase: + return UpdateUsernameUseCase(UserRepository(session)) + + @router.post("/register", response_model=UserResponse, status_code=201) @limiter.limit("10/hour") async def register( @@ -56,6 +68,17 @@ async def refresh( return await use_case.execute(data) +@router.patch("/username", response_model=UserResponse) +@limiter.limit("10/hour") +async def update_username( + request: Request, + data: UsernameUpdate, + user_id: UUID = Depends(get_current_user), + use_case: UpdateUsernameUseCase = Depends(get_update_username_use_case), +): + return await use_case.execute(user_id, data) + + @router.post("/logout", status_code=204) async def logout(_user_id: UUID = Depends(get_current_user)): # Stateful logout would require token denylist (Redis), for now we just return 204 diff --git a/backend/src/pequi/schemas/user.py b/backend/src/pequi/schemas/user.py index 5528379..2cd0ea8 100644 --- a/backend/src/pequi/schemas/user.py +++ b/backend/src/pequi/schemas/user.py @@ -4,7 +4,9 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator -_USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]*[a-z0-9]$") +_USERNAME_RE = re.compile(r"^(?=.*[a-z0-9])[a-z0-9._-]{3,30}$") +_USERNAME_VALIDATION_MSG = "O nome de usuário deve ter de 3 a 30 caracteres, sem espaços." +_USERNAME_SPACE_MSG = "O nome de usuário não pode conter espaços." class UserCreate(BaseModel): @@ -20,12 +22,11 @@ class UserCreate(BaseModel): @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: + if any(ch.isspace() for ch in v): + raise ValueError(_USERNAME_SPACE_MSG) normalized = v.lower() if not _USERNAME_RE.match(normalized): - raise ValueError( - "username must contain only letters, digits, underscores or hyphens, " - "and must start and end with a letter or digit" - ) + raise ValueError(_USERNAME_VALIDATION_MSG) return normalized @@ -63,3 +64,19 @@ class LoginRequest(BaseModel): identifier: str = Field(..., min_length=1, max_length=254) password: str + + +class UsernameUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: str = Field(..., min_length=3, max_length=30) + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + if any(ch.isspace() for ch in v): + raise ValueError(_USERNAME_SPACE_MSG) + normalized = v.lower() + if not _USERNAME_RE.match(normalized): + raise ValueError(_USERNAME_VALIDATION_MSG) + return normalized diff --git a/backend/src/pequi/use_cases/login_user.py b/backend/src/pequi/use_cases/login_user.py index 9f496ed..179cea7 100644 --- a/backend/src/pequi/use_cases/login_user.py +++ b/backend/src/pequi/use_cases/login_user.py @@ -15,10 +15,10 @@ async def execute(self, data: LoginRequest) -> AuthResponse: user = await self.user_repo.get_by_identifier(data.identifier) if not user or not verify_password(data.password, user.hashed_password): - raise UnauthorizedError("Invalid credentials") + raise UnauthorizedError("E-mail ou senha inválidos.") if not user.is_active: - raise UnauthorizedError("User is inactive") + raise UnauthorizedError("Usuário inativo.") access_token = create_access_token(subject=user.id, role=user.role) refresh_token = create_refresh_token(subject=user.id, role=user.role) diff --git a/backend/src/pequi/use_cases/register_user.py b/backend/src/pequi/use_cases/register_user.py index 199568c..71c8a66 100644 --- a/backend/src/pequi/use_cases/register_user.py +++ b/backend/src/pequi/use_cases/register_user.py @@ -14,10 +14,10 @@ def __init__(self, user_repo: UserRepository): async def execute(self, data: UserCreate) -> UserResponse: if await self.user_repo.get_by_email(data.email): - raise ConflictError("Unable to register with these credentials.") + raise ConflictError("Não foi possível cadastrar com estes dados.") if await self.user_repo.get_by_username(data.username): - raise ConflictError("Username already taken.") + raise ConflictError("Este nome de usuário já está em uso.") hashed = hash_password(data.password) user = User( diff --git a/backend/src/pequi/use_cases/update_username.py b/backend/src/pequi/use_cases/update_username.py new file mode 100644 index 0000000..8ddb0f8 --- /dev/null +++ b/backend/src/pequi/use_cases/update_username.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from pequi.core.exceptions import ConflictError, NotFoundError +from pequi.core.logging import get_logger +from pequi.repositories.user_repo import UserRepository +from pequi.schemas.user import UsernameUpdate, UserResponse + +logger = get_logger(__name__) + + +class UpdateUsernameUseCase: + def __init__(self, user_repo: UserRepository): + self.user_repo = user_repo + + async def execute(self, user_id: UUID, data: UsernameUpdate) -> UserResponse: + user = await self.user_repo.get_by_id(user_id) + if not user: + raise NotFoundError("User", str(user_id)) + + if user.username.lower() == data.username: + return UserResponse.model_validate(user) + + existing = await self.user_repo.get_by_username(data.username) + if existing and existing.id != user_id: + raise ConflictError("Este nome de usuário já está em uso.") + + updated = await self.user_repo.update_username(user_id, data.username) + if not updated: + raise NotFoundError("User", str(user_id)) + + logger.info("user.username_updated", user_id=str(user_id), username=updated.username) + return UserResponse.model_validate(updated) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 90b4474..48e086c 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -83,7 +83,7 @@ async def test_register_user_duplicate_email(create_tables, async_client: AsyncC }, ) assert response.status_code == 409 - assert response.json()["detail"] == "Unable to register with these credentials." + assert response.json()["detail"] == "Não foi possível cadastrar com estes dados." async def test_register_user_duplicate_username(create_tables, async_client: AsyncClient): @@ -106,7 +106,7 @@ async def test_register_user_duplicate_username(create_tables, async_client: Asy }, ) assert response.status_code == 409 - assert response.json()["detail"] == "Username already taken." + assert response.json()["detail"] == "Este nome de usuário já está em uso." async def test_register_duplicate_username_case_insensitive( diff --git a/backend/tests/integration/test_user_username_flow.py b/backend/tests/integration/test_user_username_flow.py index 8783b26..f412805 100644 --- a/backend/tests/integration/test_user_username_flow.py +++ b/backend/tests/integration/test_user_username_flow.py @@ -82,7 +82,7 @@ async def test_register_rejects_duplicate_username(db_session): ) ) - with pytest.raises(ConflictError, match="Username already taken"): + with pytest.raises(ConflictError, match="Este nome de usuário já está em uso"): await use_case.execute( UserCreate( email="second@example.com", @@ -106,7 +106,7 @@ async def test_register_rejects_duplicate_username_case_insensitive(db_session): ) ) - with pytest.raises(ConflictError, match="Username already taken"): + with pytest.raises(ConflictError, match="Este nome de usuário já está em uso"): await use_case.execute( UserCreate( email="other@example.com", diff --git a/backend/tests/unit/test_auth_use_cases.py b/backend/tests/unit/test_auth_use_cases.py index 59f308b..12cb249 100644 --- a/backend/tests/unit/test_auth_use_cases.py +++ b/backend/tests/unit/test_auth_use_cases.py @@ -135,7 +135,7 @@ async def test_register_user_rejects_duplicate_username(): repo = FakeUserRepository(existing_user=_user(email="other@example.com", username="takenuser")) use_case = RegisterUserUseCase(repo) - with pytest.raises(ConflictError, match="Username already taken"): + with pytest.raises(ConflictError, match="Este nome de usuário já está em uso"): await use_case.execute( UserCreate( email="new@example.com", @@ -151,7 +151,7 @@ async def test_register_user_username_case_insensitive_conflict(): repo = FakeUserRepository(existing_user=_user(email="other@example.com", username="takenuser")) use_case = RegisterUserUseCase(repo) - with pytest.raises(ConflictError, match="Username already taken"): + with pytest.raises(ConflictError, match="Este nome de usuário já está em uso"): await use_case.execute( UserCreate( email="new@example.com", diff --git a/backend/tests/unit/test_user_schema.py b/backend/tests/unit/test_user_schema.py index 87a33d9..d313cc4 100644 --- a/backend/tests/unit/test_user_schema.py +++ b/backend/tests/unit/test_user_schema.py @@ -1,6 +1,7 @@ """Testes unitários de validação Pydantic — schemas de usuário.""" import pytest +from pydantic import ValidationError from pequi.schemas.user import UserCreate @@ -26,7 +27,7 @@ def test_username_accepts_valid_characters(): def test_username_rejects_spaces(): - with pytest.raises(ValueError, match="username"): + with pytest.raises(ValueError, match="espaços"): UserCreate( email="u@example.com", username="joao silva", @@ -35,28 +36,38 @@ def test_username_rejects_spaces(): ) -def test_username_rejects_leading_hyphen(): - with pytest.raises(ValueError, match="username"): - UserCreate( - email="u@example.com", - username="-joao", - password="strongpass123", - full_name="João Silva", - ) +def test_username_accepts_dot_and_edge_punctuation(): + data = UserCreate( + email="u@example.com", + username="joao.silva_1", + password="strongpass123", + full_name="João Silva", + ) + assert data.username == "joao.silva_1" -def test_username_rejects_trailing_hyphen(): - with pytest.raises(ValueError, match="username"): +def test_username_accepts_leading_or_trailing_separator(): + data = UserCreate( + email="u@example.com", + username="_user-", + password="strongpass123", + full_name="João Silva", + ) + assert data.username == "_user-" + + +def test_username_rejects_only_separators(): + with pytest.raises(ValueError, match="nome de usuário"): UserCreate( email="u@example.com", - username="joao-", + username="...", password="strongpass123", full_name="João Silva", ) def test_username_rejects_too_short(): - with pytest.raises(ValueError): + with pytest.raises(ValidationError): UserCreate( email="u@example.com", username="ab", @@ -86,7 +97,7 @@ def test_username_minimum_length_3(): def test_username_rejects_special_chars(): - with pytest.raises(ValueError, match="username"): + with pytest.raises(ValueError, match="nome de usuário"): UserCreate( email="u@example.com", username="joao@silva", diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 328daa8..1c163c0 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { AppShellComponent } from './layout/app-shell-component/app-shell-compon import { Journey } from './features/journey/journey'; import { CheckinComponent } from './features/checkin/checkin'; import { Education } from './features/education/education'; +import { EducationArticlePage } from './features/education/education-article-page/education-article-page'; import { Comunity } from './features/comunity/comunity'; import { CommunityFeed } from './features/comunity/community-feed/community-feed'; import { CommunityPostPage } from './features/comunity/community-post-page/community-post-page'; @@ -37,6 +38,7 @@ export const routes: Routes = [ title: 'Registrar consulta', }, { path: 'education', component: Education, title: 'Educação' }, + { path: 'education/:slug', component: EducationArticlePage, title: 'Artigo' }, { path: 'comunity', component: Comunity, title: 'Comunidade' }, { path: 'comunity/feed', component: CommunityFeed, title: 'Comunidade' }, { path: 'comunity/feed/:postId', component: CommunityPostPage, title: 'Post' }, diff --git a/frontend/src/app/components/app-header/app-header.html b/frontend/src/app/components/app-header/app-header.html index d272598..41f2dc9 100644 --- a/frontend/src/app/components/app-header/app-header.html +++ b/frontend/src/app/components/app-header/app-header.html @@ -68,10 +68,9 @@ class="h-full w-full object-cover" /> } @else { - + } @@ -137,10 +136,9 @@ class="h-full w-full object-cover" /> } @else { - + } @@ -279,10 +277,9 @@ class="h-full w-full object-cover" /> } @else { - + } @@ -344,10 +341,9 @@ class="h-full w-full object-cover" /> } @else { - + } diff --git a/frontend/src/app/components/app-header/app-header.ts b/frontend/src/app/components/app-header/app-header.ts index 510229c..3024068 100644 --- a/frontend/src/app/components/app-header/app-header.ts +++ b/frontend/src/app/components/app-header/app-header.ts @@ -9,6 +9,7 @@ import { } from '@lucide/angular'; import { NotificationHubService } from '../../core/notifications/notification-hub.service'; import { AuthService } from '../../features/auth/services/auth-service'; +import { PatientProfileService } from '../../features/profile/services/patient-profile.service'; export type AppHeaderLayout = 'default' | 'withBack'; @@ -24,13 +25,19 @@ const ICON_BTN = export class AppHeader { private readonly router = inject(Router); private readonly authService = inject(AuthService); + private readonly profileService = inject(PatientProfileService); protected readonly notifications = inject(NotificationHubService); readonly layout = input('default'); - readonly userName = input('Usuário'); readonly pageTitle = input(''); - readonly avatarUrl = input(null); readonly profileLink = input('/profile'); + + readonly userName = this.profileService.displayName; + readonly userInitials = this.profileService.initials; + readonly avatarUrl = computed(() => { + const url = this.profileService.profile().avatarDataUrl.trim(); + return url || null; + }); readonly backLink = input('/home'); readonly quietNotificationButton = input(false); diff --git a/frontend/src/app/core/api-error.utils.ts b/frontend/src/app/core/api-error.utils.ts new file mode 100644 index 0000000..2c2ba33 --- /dev/null +++ b/frontend/src/app/core/api-error.utils.ts @@ -0,0 +1,101 @@ +type ValidationErrorItem = { + loc?: (string | number)[]; + msg?: string; + type?: string; +}; + +type ApiErrorBody = { + detail?: string | ValidationErrorItem[]; + message?: string; +}; + +const FIELD_LABELS: Record = { + email: 'E-mail', + username: 'Nome de usuário', + password: 'Senha', + full_name: 'Nome completo', + identifier: 'E-mail ou nome de usuário', +}; + +export function getApiErrorMessage(error: unknown, fallback: string): string { + const httpError = error as { error?: ApiErrorBody; status?: number }; + + if (httpError.status === 0) { + return 'Não foi possível conectar ao servidor. Verifique se a API está rodando.'; + } + + const body = httpError.error; + if (!body) { + return fallback; + } + + if (typeof body.detail === 'string') { + return translateKnownDetail(body.detail, fallback); + } + + if (typeof body.message === 'string' && body.message.trim()) { + return translateKnownDetail(body.message, body.message); + } + + if (Array.isArray(body.detail) && body.detail.length > 0) { + return formatValidationErrors(body.detail); + } + + return fallback; +} + +function translateKnownDetail(detail: string, fallback: string): string { + const normalized = detail.trim(); + if (!normalized) return fallback; + + const known: Record = { + 'Internal server error': 'Erro interno do servidor. Tente novamente em instantes.', + 'Invalid credentials': 'E-mail ou senha inválidos.', + 'User is inactive': 'Usuário inativo.', + 'Unable to register with these credentials.': 'Não foi possível cadastrar com estes dados.', + 'Username already taken.': 'Este nome de usuário já está em uso.', + }; + + return known[normalized] ?? normalized; +} + +function formatValidationErrors(items: ValidationErrorItem[]): string { + const messages = items.map(formatValidationError).filter(Boolean); + return messages[0] ?? 'Dados inválidos. Revise os campos e tente novamente.'; +} + +function formatValidationError(item: ValidationErrorItem): string { + const field = item.loc?.filter((part) => part !== 'body').pop()?.toString() ?? ''; + const label = FIELD_LABELS[field] ?? (field || 'Campo'); + const msg = stripValueErrorPrefix(item.msg ?? ''); + + if (msg.includes('nome de usuário')) { + return msg; + } + + switch (item.type) { + case 'missing': + return `${label}: campo obrigatório.`; + case 'string_too_short': + return `${label}: valor muito curto.`; + case 'string_too_long': + return `${label}: valor muito longo.`; + case 'value_error': + if (field === 'email') { + return 'Informe um e-mail válido.'; + } + if (msg) { + return msg; + } + return `${label}: valor inválido.`; + default: + if (msg) { + return msg; + } + return `${label}: valor inválido.`; + } +} + +function stripValueErrorPrefix(message: string): string { + return message.replace(/^Value error,\s*/i, '').trim(); +} diff --git a/frontend/src/app/features/auth/services/auth-service.spec.ts b/frontend/src/app/features/auth/services/auth-service.spec.ts index 868d406..dda8f6f 100644 --- a/frontend/src/app/features/auth/services/auth-service.spec.ts +++ b/frontend/src/app/features/auth/services/auth-service.spec.ts @@ -6,6 +6,7 @@ import { AuthService } from './auth-service'; export interface RegisterRequest { email: string; + username: string; password: string; full_name: string; } @@ -13,6 +14,7 @@ export interface RegisterRequest { export interface AuthUser { id: string; email: string; + username: string; full_name: string; role: string; is_active: boolean; @@ -24,7 +26,7 @@ export interface AuthUser { export interface RegisterResponse extends AuthUser {} export interface LoginRequest { - email: string; + identifier: string; password: string; } @@ -57,6 +59,7 @@ describe('AuthService', () => { const mockUser: AuthUser = { id: 'user-1', email: 'teste@teste.com', + username: 'sarah', full_name: 'Sarah', role: 'patient', is_active: true, @@ -108,6 +111,7 @@ describe('AuthService', () => { it('should call register with the correct payload', () => { const payload: RegisterRequest = { email: 'teste@teste.com', + username: 'sarah', password: '123456', full_name: 'Sarah', }; @@ -129,7 +133,7 @@ describe('AuthService', () => { it('should call login and save session in localStorage', () => { const payload: LoginRequest = { - email: 'teste@teste.com', + identifier: 'teste@teste.com', password: '123456', }; diff --git a/frontend/src/app/features/auth/services/auth-service.ts b/frontend/src/app/features/auth/services/auth-service.ts index 5aeee2f..17df9f1 100644 --- a/frontend/src/app/features/auth/services/auth-service.ts +++ b/frontend/src/app/features/auth/services/auth-service.ts @@ -1,10 +1,11 @@ -import { Injectable, inject } from '@angular/core'; +import { computed, Injectable, inject, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Router } from '@angular/router'; import { Observable, tap } from 'rxjs'; export interface RegisterRequest { email: string; + username: string; password: string; full_name: string; } @@ -12,6 +13,7 @@ export interface RegisterRequest { export interface AuthUser { id: string; email: string; + username: string; full_name: string; role: string; is_active: boolean; @@ -23,7 +25,7 @@ export interface AuthUser { export interface RegisterResponse extends AuthUser {} export interface LoginRequest { - email: string; + identifier: string; password: string; } @@ -47,6 +49,10 @@ export interface AuthSession { user: AuthUser; } +export interface UsernameUpdateRequest { + username: string; +} + @Injectable({ providedIn: 'root', }) @@ -56,6 +62,14 @@ export class AuthService { private readonly baseUrl = 'http://localhost:8000/v1/auth'; private readonly sessionKey = 'auth_session'; + private readonly userSignal = signal(this.readStoredUser()); + + readonly currentUser = this.userSignal.asReadonly(); + + readonly displayName = computed(() => { + const username = this.userSignal()?.username?.trim(); + return username || 'Paciente'; + }); register(payload: RegisterRequest): Observable { return this.http.post(`${this.baseUrl}/register`, payload); @@ -64,28 +78,35 @@ export class AuthService { login(payload: LoginRequest): Observable { return this.http .post(`${this.baseUrl}/login`, payload) - .pipe(tap(response => this.setSession(response))); + .pipe(tap((response) => this.setSession(response))); } -refreshToken(): Observable { - const refreshToken = this.getRefreshToken(); - - if (!refreshToken) { - this.logout(); - throw new Error('Refresh token não encontrado.'); + updateUsername(username: string): Observable { + return this.http + .patch(`${this.baseUrl}/username`, { username }) + .pipe(tap((user) => this.updateSessionUser(user))); } - const payload: RefreshTokenRequest = { - refresh_token: refreshToken, - }; + refreshToken(): Observable { + const refreshToken = this.getRefreshToken(); - return this.http - .post(`${this.baseUrl}/refresh`, payload) - .pipe(tap(response => this.setSession(response))); -} + if (!refreshToken) { + this.logout(); + throw new Error('Refresh token não encontrado.'); + } + + const payload: RefreshTokenRequest = { + refresh_token: refreshToken, + }; + + return this.http + .post(`${this.baseUrl}/refresh`, payload) + .pipe(tap((response) => this.setSession(response))); + } logout(): void { localStorage.removeItem(this.sessionKey); + this.userSignal.set(null); void this.router.navigate(['/']); } @@ -102,7 +123,14 @@ refreshToken(): Observable { } getCurrentUser(): AuthUser | null { - return this.getSession()?.user ?? null; + const current = this.userSignal(); + if (current) return current; + + const fromStorage = this.readStoredUser(); + if (fromStorage) { + this.userSignal.set(fromStorage); + } + return fromStorage; } private setSession(response: AuthTokenResponse): void { @@ -115,6 +143,20 @@ refreshToken(): Observable { }; localStorage.setItem(this.sessionKey, JSON.stringify(session)); + this.userSignal.set(response.user); + } + + private updateSessionUser(user: AuthUser): void { + const session = this.getSession(); + if (!session) return; + + const next: AuthSession = { ...session, user }; + localStorage.setItem(this.sessionKey, JSON.stringify(next)); + this.userSignal.set(user); + } + + private readStoredUser(): AuthUser | null { + return this.getSession()?.user ?? null; } private getSession(): AuthSession | null { diff --git a/frontend/src/app/features/auth/username.utils.ts b/frontend/src/app/features/auth/username.utils.ts new file mode 100644 index 0000000..52dcb6c --- /dev/null +++ b/frontend/src/app/features/auth/username.utils.ts @@ -0,0 +1,34 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export const USERNAME_VALIDATION_MESSAGE = + 'Use de 3 a 30 caracteres, sem espaços.'; + +export const USERNAME_SPACE_MESSAGE = + 'O nome de usuário não pode conter espaços.'; + +const USERNAME_PATTERN = /^(?=.*[a-z0-9])[a-z0-9._-]{3,30}$/; + +export function normalizeUsername(value: string): string { + return value.trim().toLowerCase(); +} + +export function isValidUsername(value: string): boolean { + const normalized = normalizeUsername(value); + return USERNAME_PATTERN.test(normalized); +} + +export function usernameValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = String(control.value ?? ''); + if (!value.trim()) { + return { required: true }; + } + if (/\s/.test(value)) { + return { usernameSpace: true }; + } + if (!isValidUsername(value)) { + return { username: true }; + } + return null; + }; +} diff --git a/frontend/src/app/features/education/components/education-article-card/education-article-card.html b/frontend/src/app/features/education/components/education-article-card/education-article-card.html new file mode 100644 index 0000000..3fdd690 --- /dev/null +++ b/frontend/src/app/features/education/components/education-article-card/education-article-card.html @@ -0,0 +1,52 @@ +
+ @if (article().cover_image_url) { +
+ +
+ } + +
+
+ @for (tag of article().tags; track tag.id) { + + {{ tag.name }} + + } +
+ +

{{ article().title }}

+ +

+ {{ article().summary }} +

+ +
+ {{ article().author_name }} + + +
+
+
diff --git a/frontend/src/app/features/education/components/education-article-card/education-article-card.spec.ts b/frontend/src/app/features/education/components/education-article-card/education-article-card.spec.ts new file mode 100644 index 0000000..75603f4 --- /dev/null +++ b/frontend/src/app/features/education/components/education-article-card/education-article-card.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { EducationArticleCard } from './education-article-card'; +import type { Article } from '../../models/article.models'; + +const mockArticle: Article = { + id: '1', + title: 'Tratamento multidroga', + slug: 'tratamento-multidroga', + summary: 'Entenda o esquema de tratamento.', + content: 'Conteúdo', + category: 'education', + author_name: 'Equipe Pequi', + cover_image_url: null, + cover_image_key: null, + is_published: true, + published_at: '2026-05-29T15:26:42.339Z', + reading_time_min: 4, + view_count: 0, + tags: [{ id: 't1', name: 'tratamento' }], + created_at: '2026-05-29T15:26:42.339Z', + updated_at: '2026-05-29T15:26:42.339Z', +}; + +describe('EducationArticleCard', () => { + let fixture: ComponentFixture; + let component: EducationArticleCard; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EducationArticleCard], + }).compileComponents(); + + fixture = TestBed.createComponent(EducationArticleCard); + component = fixture.componentInstance; + fixture.componentRef.setInput('article', mockArticle); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render article title and summary', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain('Tratamento multidroga'); + expect(el.textContent).toContain('Entenda o esquema de tratamento.'); + }); + + it('should emit openArticle with slug on click', () => { + const spy = vi.spyOn(component.openArticle, 'emit'); + const card = fixture.debugElement.query(By.css('[data-testid="article-card-tratamento-multidroga"]')); + card.nativeElement.click(); + expect(spy).toHaveBeenCalledWith('tratamento-multidroga'); + }); +}); diff --git a/frontend/src/app/features/education/components/education-article-card/education-article-card.ts b/frontend/src/app/features/education/components/education-article-card/education-article-card.ts new file mode 100644 index 0000000..cca23a5 --- /dev/null +++ b/frontend/src/app/features/education/components/education-article-card/education-article-card.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { Component, input, output } from '@angular/core'; +import { LucideAngularModule, LucideClock } from 'lucide-angular'; +import type { Article } from '../../models/article.models'; + +@Component({ + selector: 'app-education-article-card', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './education-article-card.html', +}) +export class EducationArticleCard { + readonly article = input.required
(); + readonly openArticle = output(); + + readonly LucideClock = LucideClock; + + onOpen(): void { + this.openArticle.emit(this.article().slug); + } + + formatPublishedAt(iso: string | null): string { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }); + } + + readingTimeLabel(minutes: number | null): string { + if (minutes == null || minutes < 1) return 'Leitura rápida'; + return `${minutes} min de leitura`; + } +} diff --git a/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.html b/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.html new file mode 100644 index 0000000..d0863cf --- /dev/null +++ b/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.html @@ -0,0 +1,24 @@ +
+ @for (tag of tags(); track tag.id) { + + } +
diff --git a/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.spec.ts b/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.spec.ts new file mode 100644 index 0000000..0d6ad5a --- /dev/null +++ b/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { EducationFilterTags } from './education-filter-tags'; + +describe('EducationFilterTags', () => { + let fixture: ComponentFixture; + let component: EducationFilterTags; + + const tags = [ + { id: 'all', label: 'Todos' }, + { id: 'cuidados', label: 'Cuidados' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EducationFilterTags], + }).compileComponents(); + + fixture = TestBed.createComponent(EducationFilterTags); + component = fixture.componentInstance; + fixture.componentRef.setInput('tags', tags); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render filter tags', () => { + const buttons = fixture.debugElement.queryAll(By.css('[role="tab"]')); + expect(buttons.length).toBe(2); + }); + + it('should emit tagChange when clicked', () => { + const spy = vi.spyOn(component.tagChange, 'emit'); + const cuidadosBtn = fixture.debugElement.query(By.css('[data-filter="cuidados"]')); + cuidadosBtn.nativeElement.click(); + expect(spy).toHaveBeenCalledWith('cuidados'); + }); +}); diff --git a/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.ts b/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.ts new file mode 100644 index 0000000..85b08f1 --- /dev/null +++ b/frontend/src/app/features/education/components/education-filter-tags/education-filter-tags.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { Component, input, output } from '@angular/core'; +import type { EducationFilterTag } from '../../models/article.models'; + +@Component({ + selector: 'app-education-filter-tags', + standalone: true, + imports: [CommonModule], + templateUrl: './education-filter-tags.html', +}) +export class EducationFilterTags { + readonly tags = input.required(); + readonly activeTag = input('all'); + readonly tagChange = output(); + + selectTag(id: string): void { + this.tagChange.emit(id); + } + + isActive(id: string): boolean { + return this.activeTag() === id; + } +} diff --git a/frontend/src/app/features/education/education-article-page/education-article-page.html b/frontend/src/app/features/education/education-article-page/education-article-page.html new file mode 100644 index 0000000..82a9433 --- /dev/null +++ b/frontend/src/app/features/education/education-article-page/education-article-page.html @@ -0,0 +1,90 @@ +
+
+ +

+ Conteúdo educativo +

+
+ + @if (loading()) { +

+ Carregando artigo... +

+ } @else if (error()) { +

+ {{ error() }} +

+ } @else if (article(); as item) { +
+ @if (item.cover_image_url) { +
+ +
+ } + +
+
+ @for (tag of item.tags; track tag.id) { + + {{ tag.name }} + + } +
+ +

+ {{ item.title }} +

+ +
+ {{ item.author_name }} + + + + @if (item.published_at) { + + + } +
+ +

+ {{ item.summary }} +

+ +
+ {{ item.content }} +
+
+
+ } +
diff --git a/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts b/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts new file mode 100644 index 0000000..bb9f70b --- /dev/null +++ b/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ActivatedRoute, provideRouter, Router, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +import { EducationArticlePage } from './education-article-page'; +import { Education } from '../education'; +import type { Article } from '../models/article.models'; + +const mockArticle: Article = { + id: '1', + title: 'Cuidados diários', + slug: 'cuidados-diarios', + summary: 'Rotina de cuidados.', + content: 'Texto completo do artigo para leitura.', + category: 'education', + author_name: 'Equipe Pequi', + cover_image_url: null, + cover_image_key: null, + is_published: true, + published_at: '2026-05-29T15:26:42.339Z', + reading_time_min: 3, + view_count: 1, + tags: [{ id: 't1', name: 'cuidados' }], + created_at: '2026-05-29T15:26:42.339Z', + updated_at: '2026-05-29T15:26:42.339Z', +}; + +describe('EducationArticlePage', () => { + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EducationArticlePage, HttpClientTestingModule], + providers: [ + provideRouter([ + { path: 'education', component: Education }, + { path: 'education/:slug', component: EducationArticlePage }, + ]), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ slug: 'cuidados-diarios' })), + }, + }, + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + + fixture = TestBed.createComponent(EducationArticlePage); + fixture.detectChanges(); + + const req = httpMock.expectOne('http://localhost:8000/v1/articles/cuidados-diarios'); + req.flush(mockArticle); + fixture.detectChanges(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should render article reader content', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('[data-testid="article-title"]')?.textContent).toContain('Cuidados diários'); + expect(el.querySelector('[data-testid="article-content"]')?.textContent).toContain( + 'Texto completo do artigo para leitura.' + ); + }); + + it('should navigate back to education list', () => { + const navigateSpy = vi.spyOn(router, 'navigate'); + fixture.componentInstance.back(); + expect(navigateSpy).toHaveBeenCalledWith(['/education']); + }); +}); diff --git a/frontend/src/app/features/education/education-article-page/education-article-page.ts b/frontend/src/app/features/education/education-article-page/education-article-page.ts new file mode 100644 index 0000000..761a8a5 --- /dev/null +++ b/frontend/src/app/features/education/education-article-page/education-article-page.ts @@ -0,0 +1,77 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, LucideArrowLeft, LucideClock } from 'lucide-angular'; +import { EMPTY } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import type { Article } from '../models/article.models'; +import { ArticlesService } from '../services/articles.service'; + +@Component({ + selector: 'app-education-article-page', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './education-article-page.html', +}) +export class EducationArticlePage implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly articlesService = inject(ArticlesService); + + readonly LucideArrowLeft = LucideArrowLeft; + readonly LucideClock = LucideClock; + + readonly loading = signal(true); + readonly error = signal(null); + readonly article = signal
(null); + + ngOnInit(): void { + this.route.paramMap + .pipe( + map((params) => params.get('slug')), + switchMap((slug) => { + this.loading.set(true); + this.error.set(null); + this.article.set(null); + + if (!slug) { + this.loading.set(false); + this.error.set('Artigo não encontrado.'); + return EMPTY; + } + + return this.articlesService.getArticle(slug); + }) + ) + .subscribe({ + next: (item) => { + this.article.set(item); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.error.set('Não foi possível carregar este conteúdo. Tente novamente.'); + }, + }); + } + + back(): void { + void this.router.navigate(['/education']); + } + + formatPublishedAt(iso: string | null): string { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric', + }); + } + + readingTimeLabel(minutes: number | null): string { + if (minutes == null || minutes < 1) return 'Leitura rápida'; + return `${minutes} min de leitura`; + } +} diff --git a/frontend/src/app/features/education/education.html b/frontend/src/app/features/education/education.html index 83a8378..8691a6f 100644 --- a/frontend/src/app/features/education/education.html +++ b/frontend/src/app/features/education/education.html @@ -1 +1,60 @@ -

education works!

+
+
+

+ Educação em saúde +

+

+ Conteúdos recomendados sobre cuidado e tratamento da hanseníase. +

+
+ +
+ + +
+ + @if (loading()) { +

+ Carregando conteúdos... +

+ } @else if (loadError()) { +

+ {{ loadError() }} +

+ } @else if (!hasArticles()) { +

+ Nenhum conteúdo encontrado para sua busca ou filtro. +

+ } @else { +

+ {{ total() }} conteúdos encontrados +

+
    + @for (article of articles(); track article.id) { +
  • + +
  • + } +
+ } +
diff --git a/frontend/src/app/features/education/education.spec.ts b/frontend/src/app/features/education/education.spec.ts index bf9ec52..7af07aa 100644 --- a/frontend/src/app/features/education/education.spec.ts +++ b/frontend/src/app/features/education/education.spec.ts @@ -1,22 +1,165 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { provideRouter, Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; import { Education } from './education'; +import { EducationArticlePage } from './education-article-page/education-article-page'; +import type { Article, ArticleListResponse } from './models/article.models'; + +const mockArticles: Article[] = [ + { + id: '1', + title: 'Cuidados diários', + slug: 'cuidados-diarios', + summary: 'Rotina de cuidados com a pele.', + content: 'Conteúdo completo.', + category: 'education', + author_name: 'Equipe Pequi', + cover_image_url: null, + cover_image_key: null, + is_published: true, + published_at: '2026-05-29T15:26:42.339Z', + reading_time_min: 3, + view_count: 2, + tags: [{ id: 't1', name: 'cuidados' }], + created_at: '2026-05-29T15:26:42.339Z', + updated_at: '2026-05-29T15:26:42.339Z', + }, + { + id: '2', + title: 'Adesão ao tratamento', + slug: 'adesao-ao-tratamento', + summary: 'Como manter a adesão medicamentosa.', + content: 'Conteúdo sobre adesão.', + category: 'education', + author_name: 'Equipe Pequi', + cover_image_url: null, + cover_image_key: null, + is_published: true, + published_at: '2026-05-29T15:26:42.339Z', + reading_time_min: 5, + view_count: 0, + tags: [{ id: 't2', name: 'tratamento' }], + created_at: '2026-05-29T15:26:42.339Z', + updated_at: '2026-05-29T15:26:42.339Z', + }, +]; describe('Education', () => { let component: Education; let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let router: Router; + + function flushInitialRequests(list: ArticleListResponse = { items: mockArticles, total: 2 }): void { + const tagsReq = httpMock.expectOne('http://localhost:8000/v1/articles/tags'); + tagsReq.flush([ + { id: 't1', name: 'cuidados' }, + { id: 't2', name: 'tratamento' }, + ]); + + const listReq = httpMock.expectOne( + (r) => r.url === 'http://localhost:8000/v1/articles' && r.params.get('category') === 'education' + ); + listReq.flush(list); + } beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [Education], + imports: [Education, HttpClientTestingModule], + providers: [ + provideRouter([ + { path: 'education', component: Education }, + { path: 'education/:slug', component: EducationArticlePage }, + ]), + ], }).compileComponents(); + httpMock = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + fixture = TestBed.createComponent(Education); component = fixture.componentInstance; - await fixture.whenStable(); + fixture.detectChanges(); + flushInitialRequests(); + fixture.detectChanges(); + }); + + afterEach(() => { + httpMock.verify(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should render search bar and filter tags', () => { + expect(fixture.nativeElement.querySelector('[data-testid="community-search"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="education-filter-tags"]')).toBeTruthy(); + }); + + it('should list recommended education articles', () => { + const list = fixture.nativeElement.querySelector('[data-testid="education-article-list"]'); + expect(list).toBeTruthy(); + expect(component.articles().length).toBe(2); + }); + + it('should navigate to article page when opening content', () => { + const navigateSpy = vi.spyOn(router, 'navigate'); + component.openArticle('cuidados-diarios'); + expect(navigateSpy).toHaveBeenCalledWith(['/education', 'cuidados-diarios']); + }); + + it('should reload articles when filter changes', () => { + component.onFilterChange('cuidados'); + fixture.detectChanges(); + + const req = httpMock.expectOne( + (r) => + r.url === 'http://localhost:8000/v1/articles' && + r.params.get('tag') === 'cuidados' && + r.params.get('category') === 'education' + ); + req.flush({ items: [mockArticles[0]], total: 1 }); + fixture.detectChanges(); + + expect(component.articles().length).toBe(1); + expect(component.articles()[0].slug).toBe('cuidados-diarios'); + }); + + it('should reload articles on debounced search', async () => { + component.onSearchChange('adesão'); + await new Promise((resolve) => setTimeout(resolve, 350)); + fixture.detectChanges(); + + const req = httpMock.expectOne( + (r) => + r.url === 'http://localhost:8000/v1/articles' && + r.params.get('search') === 'adesão' + ); + req.flush({ items: [mockArticles[1]], total: 1 }); + fixture.detectChanges(); + + expect(component.articles().length).toBe(1); + expect(component.articles()[0].slug).toBe('adesao-ao-tratamento'); + }); + + it('should show empty state when no articles match', () => { + component.onFilterChange('inexistente'); + fixture.detectChanges(); + + const req = httpMock.expectOne((r) => r.params.get('tag') === 'inexistente'); + req.flush({ items: [], total: 0 }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="education-empty"]')).toBeTruthy(); + }); + + it('should open article from card click', () => { + const navigateSpy = vi.spyOn(router, 'navigate'); + const card = fixture.debugElement.query(By.css('[data-testid="article-card-cuidados-diarios"]')); + card.nativeElement.click(); + expect(navigateSpy).toHaveBeenCalledWith(['/education', 'cuidados-diarios']); + }); }); diff --git a/frontend/src/app/features/education/education.ts b/frontend/src/app/features/education/education.ts index a9c8f09..55b7648 100644 --- a/frontend/src/app/features/education/education.ts +++ b/frontend/src/app/features/education/education.ts @@ -1,10 +1,110 @@ -import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs'; +import { CommunitySearchBar } from '../comunity/components/community-search-bar/community-search-bar'; +import { EducationArticleCard } from './components/education-article-card/education-article-card'; +import { EducationFilterTags } from './components/education-filter-tags/education-filter-tags'; +import type { Article, EducationFilterTag } from './models/article.models'; +import { ArticlesService } from './services/articles.service'; @Component({ selector: 'app-education', standalone: true, - imports: [], + imports: [CommonModule, CommunitySearchBar, EducationFilterTags, EducationArticleCard], templateUrl: './education.html', styleUrl: './education.css', }) -export class Education {} +export class Education implements OnInit, OnDestroy { + private readonly router = inject(Router); + private readonly articlesService = inject(ArticlesService); + private readonly destroy$ = new Subject(); + private readonly searchChanges$ = new Subject(); + + readonly searchQuery = signal(''); + readonly activeTag = signal('all'); + readonly articles = signal([]); + readonly total = signal(0); + readonly loading = signal(false); + readonly loadError = signal(null); + + readonly availableTags = signal([{ id: 'all', label: 'Todos' }]); + + readonly filterTags = computed(() => this.availableTags()); + + readonly hasArticles = computed(() => this.articles().length > 0); + + ngOnInit(): void { + this.loadTags(); + this.loadArticles(); + + this.searchChanges$ + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe((query) => { + this.searchQuery.set(query); + this.loadArticles(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSearchChange(query: string): void { + this.searchChanges$.next(query); + } + + onFilterChange(tag: string): void { + this.activeTag.set(tag); + this.loadArticles(); + } + + openArticle(slug: string): void { + void this.router.navigate(['/education', slug]); + } + + private loadTags(): void { + this.articlesService.listTags().subscribe({ + next: (tags) => { + this.availableTags.set([ + { id: 'all', label: 'Todos' }, + ...tags.map((tag) => ({ id: tag.name, label: tag.name })), + ]); + }, + error: () => { + this.availableTags.set([{ id: 'all', label: 'Todos' }]); + }, + }); + } + + private loadArticles(): void { + this.loading.set(true); + this.loadError.set(null); + + const activeTag = this.activeTag(); + const search = this.searchQuery().trim(); + + this.articlesService + .listArticles({ + category: 'education', + limit: 50, + offset: 0, + ...(activeTag !== 'all' ? { tag: activeTag } : {}), + ...(search ? { search } : {}), + }) + .subscribe({ + next: (response) => { + this.articles.set(response.items); + this.total.set(response.total); + this.loading.set(false); + }, + error: () => { + this.articles.set([]); + this.total.set(0); + this.loading.set(false); + this.loadError.set('Não foi possível carregar os conteúdos. Tente novamente.'); + }, + }); + } +} diff --git a/frontend/src/app/features/education/models/article.models.ts b/frontend/src/app/features/education/models/article.models.ts new file mode 100644 index 0000000..f947c55 --- /dev/null +++ b/frontend/src/app/features/education/models/article.models.ts @@ -0,0 +1,43 @@ +export type ArticleCategory = 'education' | 'news' | 'guidelines' | 'faq'; + +export interface ArticleTag { + id: string; + name: string; +} + +export interface Article { + id: string; + title: string; + slug: string; + summary: string; + content: string; + category: ArticleCategory; + author_name: string; + cover_image_url: string | null; + cover_image_key: string | null; + is_published: boolean; + published_at: string | null; + reading_time_min: number | null; + view_count: number; + tags: ArticleTag[]; + created_at: string; + updated_at: string; +} + +export interface ArticleListResponse { + items: Article[]; + total: number; +} + +export interface EducationFilterTag { + id: string; + label: string; +} + +export interface ListArticlesParams { + limit?: number; + offset?: number; + category?: ArticleCategory; + tag?: string; + search?: string; +} diff --git a/frontend/src/app/features/education/services/articles.service.spec.ts b/frontend/src/app/features/education/services/articles.service.spec.ts new file mode 100644 index 0000000..70dc3f3 --- /dev/null +++ b/frontend/src/app/features/education/services/articles.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ArticlesService } from './articles.service'; +import type { Article, ArticleListResponse } from '../models/article.models'; + +const mockArticle: Article = { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + title: 'Cuidados com a pele', + slug: 'cuidados-com-a-pele', + summary: 'Resumo do artigo educativo sobre cuidados.', + content: 'Conteúdo completo do artigo.', + category: 'education', + author_name: 'Equipe Pequi', + cover_image_url: null, + cover_image_key: null, + is_published: true, + published_at: '2026-05-29T15:26:42.339Z', + reading_time_min: 5, + view_count: 10, + tags: [{ id: 'tag-1', name: 'cuidados' }], + created_at: '2026-05-29T15:26:42.339Z', + updated_at: '2026-05-29T15:26:42.339Z', +}; + +describe('ArticlesService', () => { + let service: ArticlesService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(ArticlesService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should list articles with query params', () => { + const response: ArticleListResponse = { items: [mockArticle], total: 1 }; + + service + .listArticles({ category: 'education', tag: 'cuidados', search: 'pele', limit: 20 }) + .subscribe((data) => expect(data).toEqual(response)); + + const req = httpMock.expectOne( + (r) => + r.url === 'http://localhost:8000/v1/articles' && + r.params.get('category') === 'education' && + r.params.get('tag') === 'cuidados' && + r.params.get('search') === 'pele' && + r.params.get('limit') === '20' + ); + req.flush(response); + }); + + it('should get article by slug', () => { + service.getArticle('cuidados-com-a-pele').subscribe((data) => expect(data).toEqual(mockArticle)); + + const req = httpMock.expectOne('http://localhost:8000/v1/articles/cuidados-com-a-pele'); + req.flush(mockArticle); + }); + + it('should list tags', () => { + const tags = [{ id: 'tag-1', name: 'cuidados' }]; + + service.listTags().subscribe((data) => expect(data).toEqual(tags)); + + const req = httpMock.expectOne('http://localhost:8000/v1/articles/tags'); + req.flush(tags); + }); +}); diff --git a/frontend/src/app/features/education/services/articles.service.ts b/frontend/src/app/features/education/services/articles.service.ts new file mode 100644 index 0000000..57bcf63 --- /dev/null +++ b/frontend/src/app/features/education/services/articles.service.ts @@ -0,0 +1,34 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import type { + Article, + ArticleListResponse, + ArticleTag, + ListArticlesParams, +} from '../models/article.models'; + +@Injectable({ providedIn: 'root' }) +export class ArticlesService { + private readonly http = inject(HttpClient); + private readonly baseUrl = 'http://localhost:8000/v1/articles'; + + listArticles(params: ListArticlesParams = {}): Observable { + let httpParams = new HttpParams(); + if (params.limit != null) httpParams = httpParams.set('limit', String(params.limit)); + if (params.offset != null) httpParams = httpParams.set('offset', String(params.offset)); + if (params.category) httpParams = httpParams.set('category', params.category); + if (params.tag) httpParams = httpParams.set('tag', params.tag); + if (params.search) httpParams = httpParams.set('search', params.search); + + return this.http.get(this.baseUrl, { params: httpParams }); + } + + getArticle(slug: string): Observable
{ + return this.http.get
(`${this.baseUrl}/${encodeURIComponent(slug)}`); + } + + listTags(): Observable { + return this.http.get(`${this.baseUrl}/tags`); + } +} diff --git a/frontend/src/app/features/login/login.html b/frontend/src/app/features/login/login.html index 54c2e12..33c48d8 100644 --- a/frontend/src/app/features/login/login.html +++ b/frontend/src/app/features/login/login.html @@ -8,8 +8,13 @@

Acesse sua conta

@@ -58,7 +56,7 @@ />
-

- {{ displayName() }} -

+
+

+ {{ displayName() }} +

+ +

Nome exibido nas suas interações caso não use o modo anônimo.

- @if (profile().personal.fullName && profile().personal.socialName) { + @if (legalFullName()) {

- Nome completo na caderneta: {{ profile().personal.fullName }} + Nome completo: {{ legalFullName() }}

}
@@ -969,11 +989,20 @@

@if (showEditPersonal()) { } +@if (showEditUsername()) { + +} + @if (showEditAccount()) { { let fixture: ComponentFixture; let component: Profile; let profileService: PatientProfileService; + const authServiceMock = { + displayName: signal('Paciente'), + currentUser: signal<{ full_name: string; username: string } | null>(null), + updateUsername: vi.fn(), + }; + + const toastServiceMock = { + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }; + beforeEach(async () => { localStorage.clear(); + authServiceMock.displayName.set('Paciente'); + authServiceMock.currentUser.set(null); + await TestBed.configureTestingModule({ imports: [Profile], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { provide: AuthService, useValue: authServiceMock }, + { provide: ToastService, useValue: toastServiceMock }, + ], }).compileComponents(); profileService = TestBed.inject(PatientProfileService); @@ -32,18 +54,14 @@ describe('Profile', () => { expect(nameEl?.textContent?.trim()).toBe('Paciente'); }); - it('should show display name from personal data after save', () => { - component.onPersonalSaved({ - ...EMPTY_PERSONAL_DATA, - fullName: 'Ana Costa', - socialName: 'Ana', - }); + it('should show display name from auth username', () => { + authServiceMock.displayName.set('ana_costa'); fixture.detectChanges(); - expect(component.displayName()).toBe('Ana'); + expect(component.displayName()).toBe('ana_costa'); }); - it('should not show edit display name button', () => { - expect(fixture.nativeElement.querySelector('[data-testid="edit-display-name-button"]')).toBeFalsy(); + it('should show edit username button', () => { + expect(fixture.nativeElement.querySelector('[data-testid="edit-username-button"]')).toBeTruthy(); }); it('should open edit personal dialog', () => { diff --git a/frontend/src/app/features/profile/profile.ts b/frontend/src/app/features/profile/profile.ts index 29e21d6..4c7d436 100644 --- a/frontend/src/app/features/profile/profile.ts +++ b/frontend/src/app/features/profile/profile.ts @@ -10,6 +10,7 @@ import { import type { AccountSavePayload } from './components/profile-edit-account/profile-edit-account'; import { ProfileEditAccount } from './components/profile-edit-account/profile-edit-account'; import { ProfileEditPersonal } from './components/profile-edit-personal/profile-edit-personal'; +import { ProfileEditUsername } from './components/profile-edit-username/profile-edit-username'; import { CLASSIFICATION_OPTIONS, BLOOD_TYPE_OPTIONS, @@ -35,13 +36,16 @@ import { type ChangePasswordError, } from './services/patient-profile.service'; import { PatientMedicationService } from '../appointments/services/patient-medication.service'; +import { AuthService } from '../auth/services/auth-service'; +import { getApiErrorMessage } from '../../core/api-error.utils'; +import { ToastService } from '../../components/toast/toast.service'; export type ProfileTab = 'overview' | 'treatment'; @Component({ selector: 'app-profile', standalone: true, - imports: [ReactiveFormsModule, LucideAngularModule, ProfileEditPersonal, ProfileEditAccount], + imports: [ReactiveFormsModule, LucideAngularModule, ProfileEditPersonal, ProfileEditAccount, ProfileEditUsername], templateUrl: './profile.html', }) export class Profile { @@ -52,6 +56,8 @@ export class Profile { private readonly fb = inject(FormBuilder); private readonly profileService = inject(PatientProfileService); private readonly medicationService = inject(PatientMedicationService); + private readonly authService = inject(AuthService); + private readonly toastService = inject(ToastService); readonly LucideDownload = LucideDownload; readonly LucideKeyRound = LucideKeyRound; @@ -69,17 +75,20 @@ export class Profile { readonly profile = this.profileService.profile; readonly displayName = this.profileService.displayName; + readonly legalFullName = this.profileService.legalFullName; readonly initials = this.profileService.initials; readonly hasAvatar = this.profileService.hasAvatar; readonly hasPersonalData = this.profileService.hasPersonalData; readonly activeTab = signal('overview'); readonly showEditPersonal = signal(false); + readonly showEditUsername = signal(false); readonly showEditAccount = signal(false); readonly showAvatarMenu = signal(false); readonly accountPasswordError = signal(null); readonly personalSavedToast = signal(false); + readonly usernameSavedToast = signal(false); readonly treatmentSavedToast = signal(false); readonly accountSavedToast = signal(false); readonly avatarRemovedToast = signal(false); @@ -236,6 +245,30 @@ export class Profile { this.showEditPersonal.set(false); } + openEditUsername(): void { + this.showEditUsername.set(true); + } + + closeEditUsername(): void { + this.showEditUsername.set(false); + } + + onUsernameSaved(username: string): void { + this.authService.updateUsername(username).subscribe({ + next: () => { + this.showEditUsername.set(false); + this.showToast(this.usernameSavedToast); + }, + error: (error) => { + const message = getApiErrorMessage( + error, + 'Não foi possível atualizar o nome de usuário.' + ); + this.toastService.error('Erro ao salvar', message); + }, + }); + } + openEditAccount(): void { this.accountPasswordError.set(null); this.showEditAccount.set(true); diff --git a/frontend/src/app/features/profile/services/patient-profile.service.spec.ts b/frontend/src/app/features/profile/services/patient-profile.service.spec.ts index bbd1037..c2feb9f 100644 --- a/frontend/src/app/features/profile/services/patient-profile.service.spec.ts +++ b/frontend/src/app/features/profile/services/patient-profile.service.spec.ts @@ -1,14 +1,25 @@ import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; import { EMPTY_PERSONAL_DATA, EMPTY_TREATMENT_DATA } from '../models/patient-profile.models'; +import { AuthService } from '../../auth/services/auth-service'; import { PatientProfileService } from './patient-profile.service'; describe('PatientProfileService', () => { let service: PatientProfileService; + const authServiceMock = { + displayName: signal('Paciente'), + currentUser: signal<{ full_name: string } | null>(null), + }; + beforeEach(() => { localStorage.clear(); - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [{ provide: AuthService, useValue: authServiceMock }], + }); service = TestBed.inject(PatientProfileService); + authServiceMock.displayName.set('Paciente'); + authServiceMock.currentUser.set(null); }); it('should default display name to Paciente', () => { @@ -16,22 +27,24 @@ describe('PatientProfileService', () => { expect(service.initials()).toBe('PA'); }); - it('should prefer social name over full name', () => { - service.updatePersonal({ - ...EMPTY_PERSONAL_DATA, - fullName: 'Maria Silva', - socialName: 'Mari', - }); - expect(service.displayName()).toBe('Mari'); + it('should use username from auth session as display name', () => { + authServiceMock.displayName.set('mari_silva'); + expect(service.displayName()).toBe('mari_silva'); expect(service.initials()).toBe('MA'); }); - it('should use full name when social name is empty', () => { + it('should expose legal full name from auth user', () => { + authServiceMock.currentUser.set({ full_name: 'Maria Silva' }); + expect(service.legalFullName()).toBe('Maria Silva'); + }); + + it('should lock full name when saving personal data', () => { + authServiceMock.currentUser.set({ full_name: 'João Souza' }); service.updatePersonal({ ...EMPTY_PERSONAL_DATA, - fullName: 'João Souza', + fullName: 'Outro Nome', }); - expect(service.displayName()).toBe('João Souza'); + expect(service.profile().personal.fullName).toBe('João Souza'); }); it('should persist personal data to localStorage', () => { diff --git a/frontend/src/app/features/profile/services/patient-profile.service.ts b/frontend/src/app/features/profile/services/patient-profile.service.ts index 44c9fc2..15466b5 100644 --- a/frontend/src/app/features/profile/services/patient-profile.service.ts +++ b/frontend/src/app/features/profile/services/patient-profile.service.ts @@ -1,4 +1,5 @@ -import { computed, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { AuthService } from '../../auth/services/auth-service'; import { EMPTY_PATIENT_PROFILE, type PatientAccountData, @@ -17,25 +18,24 @@ export type ChangePasswordResult = @Injectable({ providedIn: 'root' }) export class PatientProfileService { + private readonly authService = inject(AuthService); private readonly profileSignal = signal(this.loadFromStorage()); readonly profile = this.profileSignal.asReadonly(); + readonly displayName = this.authService.displayName; - readonly displayName = computed(() => { - const { personal } = this.profileSignal(); - const social = personal.socialName.trim(); - const full = personal.fullName.trim(); - if (social) return social; - if (full) return full; - return 'Paciente'; + readonly legalFullName = computed(() => { + const fromAuth = this.authService.currentUser()?.full_name?.trim() ?? ''; + if (fromAuth) return fromAuth; + return this.profileSignal().personal.fullName.trim(); }); readonly initials = computed(() => { const name = this.displayName(); - const parts = name.split(/\s+/).filter(Boolean); - if (parts.length === 0) return 'P'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + const cleaned = name.replace(/^@/, '').trim(); + if (!cleaned) return 'P'; + if (cleaned.length <= 2) return cleaned.slice(0, 2).toUpperCase(); + return cleaned.slice(0, 2).toUpperCase(); }); readonly hasAvatar = computed(() => this.profileSignal().avatarDataUrl.trim() !== ''); @@ -90,7 +90,13 @@ export class PatientProfileService { } updatePersonal(personal: PatientPersonalData): void { - this.patch({ personal: { ...personal } }); + const lockedFullName = this.legalFullName(); + this.patch({ + personal: { + ...personal, + fullName: lockedFullName || personal.fullName, + }, + }); } updateTreatment(treatment: PatientTreatmentData): void { diff --git a/frontend/src/app/features/register/register.html b/frontend/src/app/features/register/register.html index 672040d..5b6225a 100644 --- a/frontend/src/app/features/register/register.html +++ b/frontend/src/app/features/register/register.html @@ -8,8 +8,19 @@

Crie sua conta

+ +

+ +
+
+ + + {{ images().length }} adicionada(s) + +
+ + + + + + @if (images().length > 0) { +
+ @for (img of images(); track $index) { +
+ + Preview + + + +
+ } +
+ } +
\ No newline at end of file diff --git a/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.ts b/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.ts index 3a0c41a..66be2e4 100644 --- a/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.ts +++ b/frontend/src/app/components/checkin-step-details-component/checkin-step-details-component.ts @@ -1,14 +1,64 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, Input, signal } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { LucideAngularModule, ImagePlus, Trash2 } from 'lucide-angular'; + +export interface CheckinImage { + file: File; + previewUrl: string; +} @Component({ selector: 'app-checkin-step-details-component', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], templateUrl: './checkin-step-details-component.html', styleUrl: './checkin-step-details-component.css', }) export class CheckinStepDetailsComponent { @Input({ required: true }) form!: FormGroup; + + readonly ImagePlusIcon = ImagePlus; + readonly TrashIcon = Trash2; + + images = signal([]); + + triggerFileInput(): void { + const fileInput = document.getElementById('checkin-image-upload') as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + } + + onFilesSelected(event: Event): void { + const input = event.target as HTMLInputElement; + + if (input.files && input.files.length > 0) { + const fileArray = Array.from(input.files); + + fileArray.forEach((file) => { + const reader = new FileReader(); + + reader.onload = (e) => { + const previewUrl = e.target?.result as string; + this.images.update(current => [...current, { file, previewUrl }]); + this.updateForm(); + }; + + reader.readAsDataURL(file); + }); + } + + input.value = ''; + } + + removeImage(index: number): void { + this.images.update(current => current.filter((_, i) => i !== index)); + this.updateForm(); + } + + private updateForm(): void { + const imageFiles = this.images().map(image => image.file); + this.form.get('images')?.setValue(imageFiles); + } } \ No newline at end of file diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index d97ac27..a4ff9ea 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -76,6 +76,7 @@ export class CheckinComponent implements OnInit { }), details: this.fb.group({ notes: [''], + images: [[] as File[]], }), }); From 31588090d4261903b96cc9d9a80826ebbfc3e897 Mon Sep 17 00:00:00 2001 From: Rafael Luciano <74800037+rafaellucian0@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:02:02 -0300 Subject: [PATCH 54/69] fix(community): create patient profiles for users (#63) --- .../103_user_patient_and_author_mode.py | 72 +++++++++++++++ backend/bruno/community/create_comment.bru | 12 ++- backend/bruno/community/create_post.bru | 12 ++- backend/bruno/community/get_post.bru | 2 + backend/bruno/community/list_comments.bru | 3 +- backend/bruno/community/list_posts.bru | 3 +- backend/src/pequi/models/community.py | 12 +++ backend/src/pequi/models/patient.py | 3 +- .../src/pequi/repositories/community_repo.py | 19 ++++ .../src/pequi/repositories/patient_repo.py | 11 +++ backend/src/pequi/routers/auth.py | 3 +- backend/src/pequi/schemas/community.py | 40 +++++--- backend/src/pequi/schemas/patient.py | 4 +- backend/src/pequi/use_cases/create_comment.py | 6 +- backend/src/pequi/use_cases/create_post.py | 8 +- .../pequi/use_cases/get_patient_profile.py | 4 +- backend/src/pequi/use_cases/register_user.py | 5 +- backend/src/pequi/use_cases/toggle_like.py | 4 +- .../pequi/use_cases/update_patient_profile.py | 4 +- backend/tests/e2e/test_auth_flow.py | 71 ++++++++++++++ .../tests/integration/test_community_flow.py | 92 +++++++++++++++---- .../integration/test_user_username_flow.py | 24 ++--- backend/tests/unit/test_auth_use_cases.py | 19 +++- .../unit/test_community_anonymization.py | 90 ++++++++++++++++-- .../unit/test_patient_profile_use_case.py | 28 +++++- 25 files changed, 461 insertions(+), 90 deletions(-) create mode 100644 backend/alembic/versions/103_user_patient_and_author_mode.py diff --git a/backend/alembic/versions/103_user_patient_and_author_mode.py b/backend/alembic/versions/103_user_patient_and_author_mode.py new file mode 100644 index 0000000..9957c18 --- /dev/null +++ b/backend/alembic/versions/103_user_patient_and_author_mode.py @@ -0,0 +1,72 @@ +"""make every user a patient and add community author mode + +Revision ID: 103_user_patient_and_author_mode +Revises: 103_add_username_to_users +Create Date: 2026-06-03 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "103_user_patient_and_author_mode" +down_revision: str | None = "103_add_username_to_users" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + author_mode_enum = postgresql.ENUM( + "anonymous", + "identified", + name="community_author_mode_enum", + ) + author_mode_enum.create(op.get_bind(), checkfirst=True) + + op.alter_column("patient_profiles", "health_unit_id", nullable=True) + op.create_unique_constraint( + "uq_patient_profiles_user_id", + "patient_profiles", + ["user_id"], + ) + + op.add_column( + "community_posts", + sa.Column( + "author_mode", + author_mode_enum, + server_default="anonymous", + nullable=False, + ), + ) + op.add_column( + "community_posts", + sa.Column("author_display_name", sa.Text(), nullable=True), + ) + op.add_column( + "community_comments", + sa.Column( + "author_mode", + author_mode_enum, + server_default="anonymous", + nullable=False, + ), + ) + op.add_column( + "community_comments", + sa.Column("author_display_name", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("community_comments", "author_display_name") + op.drop_column("community_comments", "author_mode") + op.drop_column("community_posts", "author_display_name") + op.drop_column("community_posts", "author_mode") + + op.drop_constraint("uq_patient_profiles_user_id", "patient_profiles", type_="unique") + op.alter_column("patient_profiles", "health_unit_id", nullable=False) + + sa.Enum(name="community_author_mode_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/bruno/community/create_comment.bru b/backend/bruno/community/create_comment.bru index c3cc4cb..8225ef3 100644 --- a/backend/bruno/community/create_comment.bru +++ b/backend/bruno/community/create_comment.bru @@ -20,7 +20,8 @@ headers { body:json { { - "content": "Ótimo post! Obrigado por compartilhar." + "content": "Otimo post! Obrigado por compartilhar.", + "author_mode": "anonymous" } } @@ -29,16 +30,19 @@ assert { res.body.id: isDefined res.body.post_id: isDefined res.body.author_anonymous_id: isDefined + res.body.author_mode: eq "anonymous" + res.body.author_display_name: isNull res.body.content: isDefined res.body.user_id: isNotDefined } docs { - Cria comentário anônimo em um post (PEQ-106). + Cria comentario em um post (PEQ-106). - Apenas pacientes podem comentar. - - Sistema usa anonymous_id do usuário. - - Nunca expõe user_id na resposta. + - author_mode: anonymous | identified + - Sistema usa anonymous_id do usuario. + - Nunca expoe user_id na resposta. - Incrementa comment_count do post. Rate limit: 30/hora. diff --git a/backend/bruno/community/create_post.bru b/backend/bruno/community/create_post.bru index 4da829b..5c536f2 100644 --- a/backend/bruno/community/create_post.bru +++ b/backend/bruno/community/create_post.bru @@ -20,9 +20,10 @@ headers { body:json { { - "title": "Minha experiência com o tratamento", + "title": "Minha experiencia com o tratamento", "content": "Estou compartilhando minha jornada de tratamento...", - "category": "experience" + "category": "experience", + "author_mode": "anonymous" } } @@ -30,6 +31,8 @@ assert { res.status: eq 201 res.body.id: isDefined res.body.author_anonymous_id: isDefined + res.body.author_mode: eq "anonymous" + res.body.author_display_name: isNull res.body.title: isDefined res.body.content: isDefined res.body.category: isDefined @@ -37,12 +40,13 @@ assert { } docs { - Cria post anônimo na comunidade (PEQ-106). + Cria post na comunidade (PEQ-106). - Apenas pacientes podem criar posts. - category: experience | question | support | news + - author_mode: anonymous | identified - Sistema gera anonymous_id automaticamente. - - Nunca expõe user_id na resposta. + - Nunca expoe user_id na resposta. Rate limit: 20/hora. } diff --git a/backend/bruno/community/get_post.bru b/backend/bruno/community/get_post.bru index 31c3526..823f921 100644 --- a/backend/bruno/community/get_post.bru +++ b/backend/bruno/community/get_post.bru @@ -21,6 +21,8 @@ assert { res.status: eq 200 res.body.id: isDefined res.body.author_anonymous_id: isDefined + res.body.author_mode: isDefined + res.body.author_display_name: isDefined res.body.user_id: isNotDefined } diff --git a/backend/bruno/community/list_comments.bru b/backend/bruno/community/list_comments.bru index 8effbe6..35afc8d 100644 --- a/backend/bruno/community/list_comments.bru +++ b/backend/bruno/community/list_comments.bru @@ -27,7 +27,8 @@ docs { Lista comentários de um post (PEQ-106). - Qualquer usuário autenticado pode acessar. - - Retorna apenas author_anonymous_id (nunca user_id). + - Retorna author_mode e author_display_name quando identificado. + - Nunca retorna user_id. - Paginado com limit e offset. - Ordenado por created_at asc. diff --git a/backend/bruno/community/list_posts.bru b/backend/bruno/community/list_posts.bru index ecb64b5..13834dc 100644 --- a/backend/bruno/community/list_posts.bru +++ b/backend/bruno/community/list_posts.bru @@ -27,7 +27,8 @@ docs { Lista posts da comunidade anônima (PEQ-106). - Qualquer usuário autenticado pode acessar. - - Retorna apenas author_anonymous_id (nunca user_id). + - Retorna author_mode e author_display_name quando identificado. + - Nunca retorna user_id. - Exclui posts moderados por padrão. - Paginado com limit e offset. diff --git a/backend/src/pequi/models/community.py b/backend/src/pequi/models/community.py index e6c0cd7..a28e0ba 100644 --- a/backend/src/pequi/models/community.py +++ b/backend/src/pequi/models/community.py @@ -63,6 +63,12 @@ class CommunityPost(Base): nullable=False, index=True, ) + author_mode = Column( + Enum("anonymous", "identified", name="community_author_mode_enum"), + nullable=False, + server_default="anonymous", + ) + author_display_name = Column(Text, nullable=True) title = Column(Text, nullable=False) content = Column(Text, nullable=False) category = Column( @@ -110,6 +116,12 @@ class CommunityComment(Base): nullable=False, index=True, ) + author_mode = Column( + Enum("anonymous", "identified", name="community_author_mode_enum"), + nullable=False, + server_default="anonymous", + ) + author_display_name = Column(Text, nullable=True) content = Column(Text, nullable=False) created_at = Column( DateTime(timezone=True), diff --git a/backend/src/pequi/models/patient.py b/backend/src/pequi/models/patient.py index bbb25ea..cb3a241 100644 --- a/backend/src/pequi/models/patient.py +++ b/backend/src/pequi/models/patient.py @@ -14,12 +14,13 @@ class PatientProfile(Base): user_id = Column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), + unique=True, nullable=False, ) health_unit_id = Column( UUID(as_uuid=True), ForeignKey("health_units.id", ondelete="RESTRICT"), - nullable=False, + nullable=True, ) date_of_birth = Column(Date, nullable=True) sex = Column(String(10)) diff --git a/backend/src/pequi/repositories/community_repo.py b/backend/src/pequi/repositories/community_repo.py index 2a6073d..1cc928e 100644 --- a/backend/src/pequi/repositories/community_repo.py +++ b/backend/src/pequi/repositories/community_repo.py @@ -10,6 +10,7 @@ CommunityLike, CommunityPost, ) +from pequi.models.user import User from pequi.schemas.community import CommentCreate, PostCreate @@ -80,9 +81,12 @@ async def create_post( ) -> CommunityPost: """Cria post anônimo — usa anonymous_id, nunca expõe user_id.""" anonymous_id = await self.get_or_create_anonymous_id(user_id) + author_display_name = await self._get_author_display_name(user_id, data.author_mode) post = CommunityPost( author_anonymous_id=anonymous_id, + author_mode=data.author_mode, + author_display_name=author_display_name, title=data.title, content=data.content, category=data.category, @@ -186,10 +190,13 @@ async def create_comment( ) -> CommunityComment: """Cria comentário anônimo — usa anonymous_id, nunca expõe user_id.""" anonymous_id = await self.get_or_create_anonymous_id(user_id) + author_display_name = await self._get_author_display_name(user_id, data.author_mode) comment = CommunityComment( post_id=post_id, author_anonymous_id=anonymous_id, + author_mode=data.author_mode, + author_display_name=author_display_name, content=data.content, ) self._session.add(comment) @@ -306,6 +313,18 @@ async def _get_post_like_count(self, post_id: UUID) -> int: result = await self._session.execute(stmt) return result.scalar_one() or 0 + async def _get_author_display_name(self, user_id: UUID, author_mode: str) -> str | None: + if author_mode == "anonymous": + return None + + stmt = select(User.full_name).where( + User.id == user_id, + User.deleted_at.is_(None), + User.is_active.is_(True), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + async def check_comment_ownership( self, comment_id: UUID, diff --git a/backend/src/pequi/repositories/patient_repo.py b/backend/src/pequi/repositories/patient_repo.py index ca93b82..04343fc 100644 --- a/backend/src/pequi/repositories/patient_repo.py +++ b/backend/src/pequi/repositories/patient_repo.py @@ -31,6 +31,17 @@ async def create(self, patient: PatientProfile) -> PatientProfile: await self.session.flush() return patient + async def get_or_create_by_user_id(self, user_id: UUID) -> PatientProfile: + patient = await self.get_by_user_id(user_id) + if patient is not None: + return patient + + patient = PatientProfile(user_id=user_id) + self.session.add(patient) + await self.session.flush() + await self.session.refresh(patient) + return patient + async def update(self, id: UUID, **fields) -> PatientProfile | None: q = ( update(PatientProfile) diff --git a/backend/src/pequi/routers/auth.py b/backend/src/pequi/routers/auth.py index 90c9f75..522c281 100644 --- a/backend/src/pequi/routers/auth.py +++ b/backend/src/pequi/routers/auth.py @@ -5,6 +5,7 @@ from pequi.core.dependencies import get_current_user, get_db from pequi.core.rate_limit import limiter +from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.user_repo import UserRepository from pequi.schemas.user import ( AuthResponse, @@ -23,7 +24,7 @@ def get_register_use_case(session: AsyncSession = Depends(get_db)) -> RegisterUserUseCase: - return RegisterUserUseCase(UserRepository(session)) + return RegisterUserUseCase(UserRepository(session), PatientRepository(session)) def get_login_use_case(session: AsyncSession = Depends(get_db)) -> LoginUserUseCase: diff --git a/backend/src/pequi/schemas/community.py b/backend/src/pequi/schemas/community.py index 616ca63..c80c9c1 100644 --- a/backend/src/pequi/schemas/community.py +++ b/backend/src/pequi/schemas/community.py @@ -5,32 +5,44 @@ class PostCreate(BaseModel): - """Payload para criação de post na comunidade anônima (PEQ-106).""" + """Payload for creating a community post.""" model_config = ConfigDict(extra="forbid") - title: str = Field(..., min_length=3, max_length=200, description="Título do post") - content: str = Field(..., min_length=10, max_length=5000, description="Conteúdo do post") + title: str = Field(..., min_length=3, max_length=200, description="Post title") + content: str = Field(..., min_length=10, max_length=5000, description="Post content") category: str = Field( ..., pattern="^(experience|question|support|news)$", - description="Categoria do post", + description="Post category", + ) + author_mode: str = Field( + ..., + pattern="^(anonymous|identified)$", + description="Public author mode", ) class CommentCreate(BaseModel): - """Payload para criação de comentário em post (PEQ-106).""" + """Payload for creating a community comment.""" model_config = ConfigDict(extra="forbid") - content: str = Field(..., min_length=3, max_length=2000, description="Conteúdo do comentário") + content: str = Field(..., min_length=3, max_length=2000, description="Comment content") + author_mode: str = Field( + ..., + pattern="^(anonymous|identified)$", + description="Public author mode", + ) class PostResponse(BaseModel): - """Resposta de post da comunidade — nunca expõe user_id, apenas anonymous_id.""" + """Community post response. Never exposes user_id.""" id: UUID author_anonymous_id: UUID + author_mode: str + author_display_name: str | None = None title: str content: str category: str @@ -45,11 +57,13 @@ class PostResponse(BaseModel): class CommentResponse(BaseModel): - """Resposta de comentário — nunca expõe user_id, apenas anonymous_id.""" + """Community comment response. Never exposes user_id.""" id: UUID post_id: UUID author_anonymous_id: UUID + author_mode: str + author_display_name: str | None = None content: str created_at: datetime updated_at: datetime @@ -58,27 +72,27 @@ class CommentResponse(BaseModel): class PostListResponse(BaseModel): - """Lista paginada de posts da comunidade.""" + """Paginated community posts.""" items: list[PostResponse] total: int class CommentListResponse(BaseModel): - """Lista de comentários de um post.""" + """Paginated community comments.""" items: list[CommentResponse] total: int class PostModerate(BaseModel): - """Payload para moderação de post (admin only).""" + """Admin-only post moderation payload.""" - is_moderated: bool = Field(..., description="Marca o post como moderado/removido") + is_moderated: bool = Field(..., description="Marks the post as moderated/removed") class DeanonymizeResponse(BaseModel): - """Resposta de deanonymização (admin only) — expõe user_id real com auditoria.""" + """Admin-only deanonymization response.""" anonymous_id: UUID user_id: UUID diff --git a/backend/src/pequi/schemas/patient.py b/backend/src/pequi/schemas/patient.py index 44d515f..d3cb88a 100644 --- a/backend/src/pequi/schemas/patient.py +++ b/backend/src/pequi/schemas/patient.py @@ -7,8 +7,8 @@ class PatientProfileRead(BaseModel): id: UUID user_id: UUID - health_unit_id: UUID - date_of_birth: date + health_unit_id: UUID | None = None + date_of_birth: date | None = None sex: str | None = None neighborhood: str | None = None city: str | None = None diff --git a/backend/src/pequi/use_cases/create_comment.py b/backend/src/pequi/use_cases/create_comment.py index 3c57b90..7e07a6e 100644 --- a/backend/src/pequi/use_cases/create_comment.py +++ b/backend/src/pequi/use_cases/create_comment.py @@ -16,10 +16,8 @@ def __init__( self._patient_repo = patient_repo async def execute(self, user_id: UUID, post_id: UUID, data: CommentCreate) -> CommentResponse: - """Cria comentário anônimo em um post.""" - patient = await self._patient_repo.get_by_user_id(user_id) - if patient is None: - raise NotFoundError("PatientProfile") + """Create a community comment for a patient user.""" + await self._patient_repo.get_or_create_by_user_id(user_id) post = await self._community_repo.get_post_by_id(post_id) if post is None: diff --git a/backend/src/pequi/use_cases/create_post.py b/backend/src/pequi/use_cases/create_post.py index 1a60d8b..67ed93b 100644 --- a/backend/src/pequi/use_cases/create_post.py +++ b/backend/src/pequi/use_cases/create_post.py @@ -1,6 +1,5 @@ from uuid import UUID -from pequi.core.exceptions import NotFoundError from pequi.repositories.community_repo import CommunityRepository from pequi.repositories.patient_repo import PatientRepository from pequi.schemas.community import PostCreate, PostResponse @@ -16,10 +15,7 @@ def __init__( self._patient_repo = patient_repo async def execute(self, user_id: UUID, data: PostCreate) -> PostResponse: - """Cria post anônimo na comunidade.""" - patient = await self._patient_repo.get_by_user_id(user_id) - if patient is None: - raise NotFoundError("PatientProfile") - + """Create a community post for a patient user.""" + await self._patient_repo.get_or_create_by_user_id(user_id) post = await self._community_repo.create_post(user_id, data) return PostResponse.model_validate(post) diff --git a/backend/src/pequi/use_cases/get_patient_profile.py b/backend/src/pequi/use_cases/get_patient_profile.py index 30e16c4..35fc55d 100644 --- a/backend/src/pequi/use_cases/get_patient_profile.py +++ b/backend/src/pequi/use_cases/get_patient_profile.py @@ -9,7 +9,5 @@ def __init__(self, patient_repo: PatientRepository): self.patient_repo = patient_repo async def execute(self, user_id: UUID) -> PatientProfileRead | None: - patient = await self.patient_repo.get_by_user_id(user_id) - if not patient: - return None + patient = await self.patient_repo.get_or_create_by_user_id(user_id) return PatientProfileRead.model_validate(patient) diff --git a/backend/src/pequi/use_cases/register_user.py b/backend/src/pequi/use_cases/register_user.py index 71c8a66..c812a8f 100644 --- a/backend/src/pequi/use_cases/register_user.py +++ b/backend/src/pequi/use_cases/register_user.py @@ -2,6 +2,7 @@ from pequi.core.exceptions import ConflictError from pequi.core.logging import get_logger from pequi.models.user import User +from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.user_repo import UserRepository from pequi.schemas.user import UserCreate, UserResponse @@ -9,8 +10,9 @@ class RegisterUserUseCase: - def __init__(self, user_repo: UserRepository): + def __init__(self, user_repo: UserRepository, patient_repo: PatientRepository): self.user_repo = user_repo + self.patient_repo = patient_repo async def execute(self, data: UserCreate) -> UserResponse: if await self.user_repo.get_by_email(data.email): @@ -28,5 +30,6 @@ async def execute(self, data: UserCreate) -> UserResponse: role="patient", ) user = await self.user_repo.add(user) + await self.patient_repo.get_or_create_by_user_id(user.id) logger.info("user.registered", user_id=str(user.id), username=user.username) return UserResponse.model_validate(user) diff --git a/backend/src/pequi/use_cases/toggle_like.py b/backend/src/pequi/use_cases/toggle_like.py index 2f6a4b6..a804284 100644 --- a/backend/src/pequi/use_cases/toggle_like.py +++ b/backend/src/pequi/use_cases/toggle_like.py @@ -22,9 +22,7 @@ async def execute(self, user_id: UUID, post_id: UUID) -> dict: Se já existe like, retorna 409 Conflict (PEQ-108). Se não existe, cria like e retorna 200. """ - patient = await self._patient_repo.get_by_user_id(user_id) - if patient is None: - raise NotFoundError("PatientProfile") + await self._patient_repo.get_or_create_by_user_id(user_id) post = await self._community_repo.get_post_by_id(post_id) if post is None: diff --git a/backend/src/pequi/use_cases/update_patient_profile.py b/backend/src/pequi/use_cases/update_patient_profile.py index eba900e..08491be 100644 --- a/backend/src/pequi/use_cases/update_patient_profile.py +++ b/backend/src/pequi/use_cases/update_patient_profile.py @@ -9,9 +9,7 @@ def __init__(self, patient_repo: PatientRepository): self.patient_repo = patient_repo async def execute(self, user_id: UUID, data: PatientProfileUpdate) -> PatientProfileRead | None: - patient = await self.patient_repo.get_by_user_id(user_id) - if not patient: - return None + patient = await self.patient_repo.get_or_create_by_user_id(user_id) fields = {k: v for k, v in data.model_dump().items() if v is not None} if not fields: return PatientProfileRead.model_validate(patient) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 48e086c..0d42742 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -23,6 +23,77 @@ async def test_register_user_success(create_tables, async_client: AsyncClient): assert "hashed_password" not in data +async def test_register_creates_patient_profile(create_tables, async_client: AsyncClient): + register_response = await async_client.post( + "/v1/auth/register", + json={ + "email": "patient-profile@example.com", + "username": "patientprofile", + "password": "strongpassword123", + "full_name": "Patient Profile", + }, + ) + assert register_response.status_code == 201 + + login_response = await async_client.post( + "/v1/auth/login", + json={ + "identifier": "patient-profile@example.com", + "password": "strongpassword123", + }, + ) + token = login_response.json()["access_token"] + + profile_response = await async_client.get( + "/v1/patients/me", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert profile_response.status_code == 200 + profile = profile_response.json() + assert profile["user_id"] == register_response.json()["id"] + + +async def test_registered_user_can_create_identified_community_post( + create_tables, + async_client: AsyncClient, +): + await async_client.post( + "/v1/auth/register", + json={ + "email": "community-profile@example.com", + "username": "communityprofile", + "password": "strongpassword123", + "full_name": "Community Author", + }, + ) + login_response = await async_client.post( + "/v1/auth/login", + json={ + "identifier": "community-profile@example.com", + "password": "strongpassword123", + }, + ) + token = login_response.json()["access_token"] + + post_response = await async_client.post( + "/v1/community/posts", + headers={"Authorization": f"Bearer {token}"}, + json={ + "title": "Minha experiencia", + "content": "Estou compartilhando minha jornada de tratamento.", + "category": "experience", + "author_mode": "identified", + }, + ) + + assert post_response.status_code == 201 + post = post_response.json() + assert post["author_mode"] == "identified" + assert post["author_display_name"] == "Community Author" + assert "user_id" not in post + + async def test_register_username_is_returned_normalized(create_tables, async_client: AsyncClient): response = await async_client.post( "/v1/auth/register", diff --git a/backend/tests/integration/test_community_flow.py b/backend/tests/integration/test_community_flow.py index 5a947f5..65904cf 100644 --- a/backend/tests/integration/test_community_flow.py +++ b/backend/tests/integration/test_community_flow.py @@ -51,6 +51,7 @@ async def test_patient_creates_post_anonymously(create_tables, db_session): title="Minha experiência com o tratamento", content="Estou compartilhando minha jornada...", category="experience", + author_mode="anonymous", ) community_repo = CommunityRepository(db_session) @@ -69,6 +70,31 @@ async def test_patient_creates_post_anonymously(create_tables, db_session): assert mapping.user_id == patient_user.id +@pytest.mark.asyncio +async def test_patient_creates_identified_post_without_exposing_user_id(create_tables, db_session): + """Identified posts expose chosen display name, never user_id.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="identified@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + data = PostCreate( + title="Minha experiência identificada", + content="Quero aparecer com meu nome neste relato.", + category="experience", + author_mode="identified", + ) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + use_case = CreatePostUseCase(community_repo, patient_repo) + result = await use_case.execute(patient_user.id, data) + + response_dict = result.model_dump() + assert response_dict["author_mode"] == "identified" + assert response_dict["author_display_name"] == patient_user.full_name + assert "user_id" not in response_dict + + @pytest.mark.asyncio async def test_anonymous_id_is_stable_for_user(create_tables, db_session): """Anonymous ID remains the same across multiple posts from the same user.""" @@ -80,10 +106,14 @@ async def test_anonymous_id_is_stable_for_user(create_tables, db_session): patient_repo = PatientRepository(db_session) use_case = CreatePostUseCase(community_repo, patient_repo) - data1 = PostCreate(title="Post 1", content="Content 123", category="experience") + data1 = PostCreate( + title="Post 1", content="Content 123", category="experience", author_mode="anonymous" + ) post1 = await use_case.execute(patient_user.id, data1) - data2 = PostCreate(title="Post 2", content="Content 456", category="question") + data2 = PostCreate( + title="Post 2", content="Content 456", category="question", author_mode="anonymous" + ) post2 = await use_case.execute(patient_user.id, data2) # Same anonymous_id for both posts @@ -103,7 +133,9 @@ async def test_different_users_have_different_anonymous_ids(create_tables, db_se patient_repo = PatientRepository(db_session) use_case = CreatePostUseCase(community_repo, patient_repo) - data = PostCreate(title="Test", content="Test content", category="experience") + data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post1 = await use_case.execute(user1.id, data) post2 = await use_case.execute(user2.id, data) @@ -122,7 +154,9 @@ async def test_user_id_never_appears_in_post_response(create_tables, db_session) patient_repo = PatientRepository(db_session) use_case = CreatePostUseCase(community_repo, patient_repo) - data = PostCreate(title="Test", content="Test content", category="experience") + data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) result = await use_case.execute(patient_user.id, data) # Convert to dict to check all fields @@ -142,12 +176,14 @@ async def test_patient_can_comment_on_post(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) # Create comment - comment_data = CommentCreate(content="Great post!") + comment_data = CommentCreate(content="Great post!", author_mode="anonymous") comment_use_case = CreateCommentUseCase(community_repo, patient_repo) comment = await comment_use_case.execute(patient_user.id, post.id, comment_data) @@ -173,7 +209,9 @@ async def test_duplicate_like_returns_409_conflict(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -199,7 +237,9 @@ async def test_list_posts_excludes_moderated_content(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create two posts - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post1 = await post_use_case.execute(patient_user.id, post_data) post2 = await post_use_case.execute(patient_user.id, post_data) @@ -227,7 +267,9 @@ async def test_user_can_delete_own_post(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -254,7 +296,9 @@ async def test_user_cannot_delete_others_post(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create post as user1 - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(user1.id, post_data) @@ -282,7 +326,9 @@ async def test_admin_can_moderate_post(create_tables, db_session): audit_repo = AuditRepository(db_session) # Create post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -324,7 +370,9 @@ async def test_admin_can_deanonymize_with_audit(create_tables, db_session): audit_repo = AuditRepository(db_session) # Create post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -364,7 +412,9 @@ async def test_soft_deleted_posts_not_visible(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create and delete post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -392,14 +442,24 @@ async def test_list_comments_for_post(create_tables, db_session): patient_repo = PatientRepository(db_session) # Create post - post_data = PostCreate(title="Test", content="Test content", category="experience") + post_data = PostCreate( + title="Test", content="Test content", category="experience", author_mode="anonymous" + ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) # Create comments comment_use_case = CreateCommentUseCase(community_repo, patient_repo) - await comment_use_case.execute(patient_user.id, post.id, CommentCreate(content="Comment 1")) - await comment_use_case.execute(patient_user.id, post.id, CommentCreate(content="Comment 2")) + await comment_use_case.execute( + patient_user.id, + post.id, + CommentCreate(content="Comment 1", author_mode="anonymous"), + ) + await comment_use_case.execute( + patient_user.id, + post.id, + CommentCreate(content="Comment 2", author_mode="anonymous"), + ) # List comments list_comments_use_case = ListCommentsUseCase(community_repo) diff --git a/backend/tests/integration/test_user_username_flow.py b/backend/tests/integration/test_user_username_flow.py index f412805..63f01c2 100644 --- a/backend/tests/integration/test_user_username_flow.py +++ b/backend/tests/integration/test_user_username_flow.py @@ -7,6 +7,7 @@ import pytest from pequi.core.exceptions import ConflictError, UnauthorizedError +from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.user_repo import UserRepository from pequi.schemas.user import LoginRequest, UserCreate from pequi.use_cases.login_user import LoginUserUseCase @@ -15,9 +16,13 @@ pytestmark = pytest.mark.asyncio +def _register_use_case(db_session): + return RegisterUserUseCase(UserRepository(db_session), PatientRepository(db_session)) + + async def test_register_persists_username(db_session): repo = UserRepository(db_session) - use_case = RegisterUserUseCase(repo) + use_case = _register_use_case(db_session) result = await use_case.execute( UserCreate( @@ -36,8 +41,7 @@ async def test_register_persists_username(db_session): async def test_register_username_stored_lowercase(db_session): - repo = UserRepository(db_session) - use_case = RegisterUserUseCase(repo) + use_case = _register_use_case(db_session) result = await use_case.execute( UserCreate( @@ -53,7 +57,7 @@ async def test_register_username_stored_lowercase(db_session): async def test_get_by_username_case_insensitive(db_session): repo = UserRepository(db_session) - use_case = RegisterUserUseCase(repo) + use_case = _register_use_case(db_session) await use_case.execute( UserCreate( @@ -70,8 +74,7 @@ async def test_get_by_username_case_insensitive(db_session): async def test_register_rejects_duplicate_username(db_session): - repo = UserRepository(db_session) - use_case = RegisterUserUseCase(repo) + use_case = _register_use_case(db_session) await use_case.execute( UserCreate( @@ -94,8 +97,7 @@ async def test_register_rejects_duplicate_username(db_session): async def test_register_rejects_duplicate_username_case_insensitive(db_session): - repo = UserRepository(db_session) - use_case = RegisterUserUseCase(repo) + use_case = _register_use_case(db_session) await use_case.execute( UserCreate( @@ -119,7 +121,7 @@ async def test_register_rejects_duplicate_username_case_insensitive(db_session): async def test_login_by_email(db_session): repo = UserRepository(db_session) - await RegisterUserUseCase(repo).execute( + await _register_use_case(db_session).execute( UserCreate( email="emaillogin@example.com", username="emailloginuser", @@ -138,7 +140,7 @@ async def test_login_by_email(db_session): async def test_login_by_username(db_session): repo = UserRepository(db_session) - await RegisterUserUseCase(repo).execute( + await _register_use_case(db_session).execute( UserCreate( email="userlogin@example.com", username="userloginuser", @@ -157,7 +159,7 @@ async def test_login_by_username(db_session): async def test_login_by_username_case_insensitive(db_session): repo = UserRepository(db_session) - await RegisterUserUseCase(repo).execute( + await _register_use_case(db_session).execute( UserCreate( email="cilogin@example.com", username="ciloginuser", diff --git a/backend/tests/unit/test_auth_use_cases.py b/backend/tests/unit/test_auth_use_cases.py index 12cb249..d021eab 100644 --- a/backend/tests/unit/test_auth_use_cases.py +++ b/backend/tests/unit/test_auth_use_cases.py @@ -65,6 +65,15 @@ async def get_by_id(self, user_id): return self.existing_user +class FakePatientRepository: + def __init__(self): + self.created_for_user_id = None + + async def get_or_create_by_user_id(self, user_id): + self.created_for_user_id = user_id + return SimpleNamespace(user_id=user_id) + + def _hashed_password(_password): return "hashed" @@ -96,8 +105,9 @@ async def _token_is_revoked(_payload): async def test_register_user_hashes_password_and_always_creates_patient(monkeypatch): repo = FakeUserRepository() + patient_repo = FakePatientRepository() monkeypatch.setattr("pequi.use_cases.register_user.hash_password", _hashed_password) - use_case = RegisterUserUseCase(repo) + use_case = RegisterUserUseCase(repo, patient_repo) result = await use_case.execute( UserCreate( @@ -114,11 +124,12 @@ async def test_register_user_hashes_password_and_always_creates_patient(monkeypa assert repo.added_user is not None assert repo.added_user.hashed_password == "hashed" assert repo.added_user.full_name == "New Patient" + assert patient_repo.created_for_user_id == repo.added_user.id async def test_register_user_rejects_duplicate_email(): repo = FakeUserRepository(existing_user=_user(email="taken@example.com", username="taken")) - use_case = RegisterUserUseCase(repo) + use_case = RegisterUserUseCase(repo, FakePatientRepository()) with pytest.raises(ConflictError): await use_case.execute( @@ -133,7 +144,7 @@ async def test_register_user_rejects_duplicate_email(): async def test_register_user_rejects_duplicate_username(): repo = FakeUserRepository(existing_user=_user(email="other@example.com", username="takenuser")) - use_case = RegisterUserUseCase(repo) + use_case = RegisterUserUseCase(repo, FakePatientRepository()) with pytest.raises(ConflictError, match="Este nome de usuário já está em uso"): await use_case.execute( @@ -149,7 +160,7 @@ async def test_register_user_rejects_duplicate_username(): async def test_register_user_username_case_insensitive_conflict(): """Username 'TakenUser' deve conflitar com 'takenuser' já cadastrado.""" repo = FakeUserRepository(existing_user=_user(email="other@example.com", username="takenuser")) - use_case = RegisterUserUseCase(repo) + use_case = RegisterUserUseCase(repo, FakePatientRepository()) with pytest.raises(ConflictError, match="Este nome de usuário já está em uso"): await use_case.execute( diff --git a/backend/tests/unit/test_community_anonymization.py b/backend/tests/unit/test_community_anonymization.py index 0753ca6..51be02d 100644 --- a/backend/tests/unit/test_community_anonymization.py +++ b/backend/tests/unit/test_community_anonymization.py @@ -7,47 +7,113 @@ def test_post_create_title_min_length(): """Post title must be at least 3 characters.""" with pytest.raises(ValidationError): - PostCreate(title="ab", content="Valid content", category="experience") + PostCreate( + title="ab", + content="Valid content", + category="experience", + author_mode="anonymous", + ) def test_post_create_title_max_length(): """Post title must be at most 200 characters.""" with pytest.raises(ValidationError): - PostCreate(title="a" * 201, content="Valid content", category="experience") + PostCreate( + title="a" * 201, + content="Valid content", + category="experience", + author_mode="anonymous", + ) def test_post_create_content_min_length(): """Post content must be at least 10 characters.""" with pytest.raises(ValidationError): - PostCreate(title="Valid title", content="short", category="experience") + PostCreate( + title="Valid title", + content="short", + category="experience", + author_mode="anonymous", + ) def test_post_create_content_max_length(): """Post content must be at most 5000 characters.""" with pytest.raises(ValidationError): - PostCreate(title="Valid title", content="a" * 5001, category="experience") + PostCreate( + title="Valid title", + content="a" * 5001, + category="experience", + author_mode="anonymous", + ) def test_post_create_category_must_be_valid(): """Post category must be one of: experience, question, support, news.""" with pytest.raises(ValidationError): - PostCreate(title="Valid title", content="Valid content", category="invalid") + PostCreate( + title="Valid title", + content="Valid content", + category="invalid", + author_mode="anonymous", + ) # Valid categories should pass for category in ["experience", "question", "support", "news"]: - PostCreate(title="Valid title", content="Valid content", category=category) + PostCreate( + title="Valid title", + content="Valid content", + category=category, + author_mode="anonymous", + ) + + +def test_post_create_author_mode_is_required_and_valid(): + """User must explicitly choose anonymous or identified posting.""" + with pytest.raises(ValidationError): + PostCreate(title="Valid title", content="Valid content", category="experience") + + with pytest.raises(ValidationError): + PostCreate( + title="Valid title", + content="Valid content", + category="experience", + author_mode="invalid", + ) + + for author_mode in ["anonymous", "identified"]: + post = PostCreate( + title="Valid title", + content="Valid content", + category="experience", + author_mode=author_mode, + ) + assert post.author_mode == author_mode def test_comment_create_content_min_length(): """Comment content must be at least 3 characters.""" with pytest.raises(ValidationError): - CommentCreate(content="ab") + CommentCreate(content="ab", author_mode="anonymous") def test_comment_create_content_max_length(): """Comment content must be at most 2000 characters.""" with pytest.raises(ValidationError): - CommentCreate(content="a" * 2001) + CommentCreate(content="a" * 2001, author_mode="anonymous") + + +def test_comment_create_author_mode_is_required_and_valid(): + """User must explicitly choose anonymous or identified commenting.""" + with pytest.raises(ValidationError): + CommentCreate(content="Valid comment") + + with pytest.raises(ValidationError): + CommentCreate(content="Valid comment", author_mode="invalid") + + for author_mode in ["anonymous", "identified"]: + comment = CommentCreate(content="Valid comment", author_mode=author_mode) + assert comment.author_mode == author_mode def test_post_response_never_exposes_user_id(): @@ -60,11 +126,15 @@ def test_post_response_never_exposes_user_id(): response_fields = PostResponse.model_fields assert "user_id" not in response_fields assert "author_anonymous_id" in response_fields + assert "author_mode" in response_fields + assert "author_display_name" in response_fields # Verify that a valid response can be created with anonymous_id post_data = { "id": uuid4(), "author_anonymous_id": uuid4(), + "author_mode": "anonymous", + "author_display_name": None, "title": "Test Post", "content": "Test content", "category": "experience", @@ -89,12 +159,16 @@ def test_comment_response_never_exposes_user_id(): response_fields = CommentResponse.model_fields assert "user_id" not in response_fields assert "author_anonymous_id" in response_fields + assert "author_mode" in response_fields + assert "author_display_name" in response_fields # Verify that a valid response can be created with anonymous_id comment_data = { "id": uuid4(), "post_id": uuid4(), "author_anonymous_id": uuid4(), + "author_mode": "anonymous", + "author_display_name": None, "content": "Test comment", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", diff --git a/backend/tests/unit/test_patient_profile_use_case.py b/backend/tests/unit/test_patient_profile_use_case.py index 066d473..a0bea33 100644 --- a/backend/tests/unit/test_patient_profile_use_case.py +++ b/backend/tests/unit/test_patient_profile_use_case.py @@ -33,6 +33,7 @@ class FakePatientRepository: def __init__(self, *, patient=None, refreshed=_DEFAULT_REFRESHED): self.patient = patient self.refreshed = patient if refreshed is _DEFAULT_REFRESHED else refreshed + self.use_default_refreshed = refreshed is _DEFAULT_REFRESHED self.updated_id = None self.updated_fields = None @@ -41,9 +42,23 @@ async def get_by_user_id(self, user_id): return None return self.patient + async def get_or_create_by_user_id(self, user_id): + patient = await self.get_by_user_id(user_id) + if patient is not None: + return patient + + self.patient = _patient(user_id=user_id, health_unit_id=None, date_of_birth=None) + if self.use_default_refreshed: + self.refreshed = self.patient + return self.patient + async def update(self, patient_id, **fields): self.updated_id = patient_id self.updated_fields = fields + if self.use_default_refreshed and self.patient is not None: + for key, value in fields.items(): + setattr(self.patient, key, value) + self.refreshed = self.patient return self.refreshed async def get_by_id(self, patient_id): @@ -52,14 +67,19 @@ async def get_by_id(self, patient_id): return self.refreshed -async def test_update_patient_profile_returns_none_when_profile_does_not_exist(): +async def test_update_patient_profile_creates_minimal_profile_when_missing(): repo = FakePatientRepository(patient=None) use_case = UpdatePatientProfileUseCase(repo) + user_id = uuid4() - result = await use_case.execute(uuid4(), PatientProfileUpdate(city="Recife")) + result = await use_case.execute(user_id, PatientProfileUpdate(city="Recife")) - assert result is None - assert repo.updated_fields is None + assert result is not None + assert result.user_id == user_id + assert result.city == "Recife" + assert result.health_unit_id is None + assert result.date_of_birth is None + assert repo.updated_fields == {"city": "Recife"} async def test_update_patient_profile_empty_patch_returns_current_profile_without_writing(): From e0057efcc7e863406dcc51fb34243281bfaaec57 Mon Sep 17 00:00:00 2001 From: Leila Biggi <87096464+lawtherea@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:39:27 -0300 Subject: [PATCH 55/69] =?UTF-8?q?PEQ-141:=20Integra=20se=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20comunidade=20com=20rotas=20corrigidas=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(community): create patient profiles for users * feat/integrated community feature with back --------- Co-authored-by: rafaellucian0 --- .../103_user_patient_and_author_mode.py | 23 +- .../versions/104_community_post_categories.py | 62 +++ .../versions/105_community_author_username.py | 65 ++++ backend/bruno/community/create_post.bru | 6 +- backend/bruno/community/delete_comment.bru | 29 ++ backend/src/pequi/models/community.py | 6 +- .../src/pequi/repositories/community_repo.py | 113 ++++-- .../src/pequi/repositories/patient_repo.py | 15 +- backend/src/pequi/routers/community.py | 19 +- backend/src/pequi/schemas/community.py | 25 +- backend/src/pequi/use_cases/delete_comment.py | 33 ++ .../pequi/use_cases/export_account_data.py | 2 +- backend/src/pequi/use_cases/toggle_like.py | 17 +- backend/tests/e2e/test_auth_flow.py | 4 +- .../integration/test_account_deletion.py | 2 +- .../test_account_export_and_consents.py | 3 +- .../tests/integration/test_community_flow.py | 167 ++++++-- .../unit/test_community_anonymization.py | 33 +- .../community-feed/community-feed.html | 9 +- .../community-feed/community-feed.spec.ts | 76 +++- .../comunity/community-feed/community-feed.ts | 73 +++- .../community-post-page.html | 9 +- .../community-post-page.ts | 86 +++- .../community-author-avatar.html | 7 + .../community-author-avatar.spec.ts | 10 + .../community-author-avatar.ts | 4 +- .../community-author-mode-picker.html | 2 +- .../community-create-post.html | 6 +- .../community-create-post.ts | 4 +- .../community-delete-confirm.html | 4 +- .../community-post-card.html | 3 +- .../community-post-card.spec.ts | 2 +- .../community-post-category-picker.html | 2 +- .../community-post-detail.html | 104 ++--- .../community-post-detail.spec.ts | 82 +--- .../community-post-detail.ts | 28 +- .../comunity/models/community-api.models.ts | 56 +++ .../comunity/models/community.models.ts | 6 + .../comunity/services/community-api.mapper.ts | 241 ++++++++++++ .../services/community-posts.service.spec.ts | 368 +++++++++++++----- .../services/community-posts.service.ts | 317 +++++++++------ 41 files changed, 1615 insertions(+), 508 deletions(-) create mode 100644 backend/alembic/versions/104_community_post_categories.py create mode 100644 backend/alembic/versions/105_community_author_username.py create mode 100644 backend/bruno/community/delete_comment.bru create mode 100644 backend/src/pequi/use_cases/delete_comment.py create mode 100644 frontend/src/app/features/comunity/models/community-api.models.ts create mode 100644 frontend/src/app/features/comunity/services/community-api.mapper.ts diff --git a/backend/alembic/versions/103_user_patient_and_author_mode.py b/backend/alembic/versions/103_user_patient_and_author_mode.py index 9957c18..7163647 100644 --- a/backend/alembic/versions/103_user_patient_and_author_mode.py +++ b/backend/alembic/versions/103_user_patient_and_author_mode.py @@ -67,6 +67,27 @@ def downgrade() -> None: op.drop_column("community_posts", "author_mode") op.drop_constraint("uq_patient_profiles_user_id", "patient_profiles", type_="unique") - op.alter_column("patient_profiles", "health_unit_id", nullable=False) + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM patient_profiles + WHERE health_unit_id IS NULL + ) THEN + RAISE EXCEPTION + 'Cannot downgrade: patient_profiles.health_unit_id has NULL values. ' + 'Assign a health_unit_id to minimal patient profiles before rollback.'; + END IF; + END $$; + """ + ) + op.alter_column( + "patient_profiles", + "health_unit_id", + existing_type=postgresql.UUID(as_uuid=True), + nullable=False, + ) sa.Enum(name="community_author_mode_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/104_community_post_categories.py b/backend/alembic/versions/104_community_post_categories.py new file mode 100644 index 0000000..ead3ffb --- /dev/null +++ b/backend/alembic/versions/104_community_post_categories.py @@ -0,0 +1,62 @@ +"""community posts support multiple categories + +Revision ID: 104_community_post_categories +Revises: 103_user_patient_and_author_mode +Create Date: 2026-06-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "104_community_post_categories" +down_revision: str | None = "103_user_patient_and_author_mode" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +POST_CATEGORY_ENUM = postgresql.ENUM( + "experience", + "question", + "support", + "news", + name="post_category_enum", + create_type=False, +) + + +def upgrade() -> None: + op.add_column( + "community_posts", + sa.Column("categories", postgresql.ARRAY(POST_CATEGORY_ENUM), nullable=True), + ) + op.execute( + """ + UPDATE community_posts + SET categories = ARRAY[category]::post_category_enum[] + WHERE categories IS NULL + """ + ) + op.alter_column("community_posts", "categories", nullable=False) + op.drop_column("community_posts", "category") + + +def downgrade() -> None: + op.add_column( + "community_posts", + sa.Column( + "category", + POST_CATEGORY_ENUM, + nullable=True, + ), + ) + op.execute( + """ + UPDATE community_posts + SET category = categories[1] + WHERE category IS NULL AND categories IS NOT NULL + """ + ) + op.alter_column("community_posts", "category", nullable=False) + op.drop_column("community_posts", "categories") diff --git a/backend/alembic/versions/105_community_author_username.py b/backend/alembic/versions/105_community_author_username.py new file mode 100644 index 0000000..e9eb6af --- /dev/null +++ b/backend/alembic/versions/105_community_author_username.py @@ -0,0 +1,65 @@ +"""store username in community author_display_name + +Revision ID: 105_community_author_username +Revises: 104_community_post_categories +Create Date: 2026-06-04 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "105_community_author_username" +down_revision: str | None = "104_community_post_categories" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + UPDATE community_posts AS cp + SET author_display_name = u.username + FROM community_anonymous_map AS cam + JOIN users AS u ON u.id = cam.user_id + WHERE cp.author_anonymous_id = cam.anonymous_id + AND cp.author_mode = 'identified' + AND cp.author_display_name IS NOT NULL + """ + ) + op.execute( + """ + UPDATE community_comments AS cc + SET author_display_name = u.username + FROM community_anonymous_map AS cam + JOIN users AS u ON u.id = cam.user_id + WHERE cc.author_anonymous_id = cam.anonymous_id + AND cc.author_mode = 'identified' + AND cc.author_display_name IS NOT NULL + """ + ) + + +def downgrade() -> None: + op.execute( + """ + UPDATE community_posts AS cp + SET author_display_name = u.full_name + FROM community_anonymous_map AS cam + JOIN users AS u ON u.id = cam.user_id + WHERE cp.author_anonymous_id = cam.anonymous_id + AND cp.author_mode = 'identified' + AND cp.author_display_name IS NOT NULL + """ + ) + op.execute( + """ + UPDATE community_comments AS cc + SET author_display_name = u.full_name + FROM community_anonymous_map AS cam + JOIN users AS u ON u.id = cam.user_id + WHERE cc.author_anonymous_id = cam.anonymous_id + AND cc.author_mode = 'identified' + AND cc.author_display_name IS NOT NULL + """ + ) diff --git a/backend/bruno/community/create_post.bru b/backend/bruno/community/create_post.bru index 5c536f2..8dcc152 100644 --- a/backend/bruno/community/create_post.bru +++ b/backend/bruno/community/create_post.bru @@ -22,7 +22,7 @@ body:json { { "title": "Minha experiencia com o tratamento", "content": "Estou compartilhando minha jornada de tratamento...", - "category": "experience", + "categories": ["experience", "support"], "author_mode": "anonymous" } } @@ -35,7 +35,7 @@ assert { res.body.author_display_name: isNull res.body.title: isDefined res.body.content: isDefined - res.body.category: isDefined + res.body.categories: isDefined res.body.user_id: isNotDefined } @@ -43,7 +43,7 @@ docs { Cria post na comunidade (PEQ-106). - Apenas pacientes podem criar posts. - - category: experience | question | support | news + - categories: experience | question | support | news (1 a 4 tags) - author_mode: anonymous | identified - Sistema gera anonymous_id automaticamente. - Nunca expoe user_id na resposta. diff --git a/backend/bruno/community/delete_comment.bru b/backend/bruno/community/delete_comment.bru new file mode 100644 index 0000000..00d18ee --- /dev/null +++ b/backend/bruno/community/delete_comment.bru @@ -0,0 +1,29 @@ +meta { + name: Delete Comment + type: http + seq: 7 +} + +delete { + url: {{baseUrl}}/v1/community/posts/{{postId}}/comments/{{commentId}} + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: isDefined +} + +docs { + Soft delete de comentário. + + - Próprio autor ou admin podem excluir. + - Decrementa comment_count do post. + - Comentário deixa de aparecer em GET /comments. + + Rate limit: 30/hora. +} diff --git a/backend/src/pequi/models/community.py b/backend/src/pequi/models/community.py index a28e0ba..be19c78 100644 --- a/backend/src/pequi/models/community.py +++ b/backend/src/pequi/models/community.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, Text, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import relationship from pequi.database import Base @@ -71,8 +71,8 @@ class CommunityPost(Base): author_display_name = Column(Text, nullable=True) title = Column(Text, nullable=False) content = Column(Text, nullable=False) - category = Column( - Enum("experience", "question", "support", "news", name="post_category_enum"), + categories = Column( + ARRAY(Enum("experience", "question", "support", "news", name="post_category_enum")), nullable=False, ) is_pinned = Column(Boolean, server_default="false", nullable=False) diff --git a/backend/src/pequi/repositories/community_repo.py b/backend/src/pequi/repositories/community_repo.py index 1cc928e..d5fa7b5 100644 --- a/backend/src/pequi/repositories/community_repo.py +++ b/backend/src/pequi/repositories/community_repo.py @@ -1,7 +1,9 @@ from datetime import UTC, datetime from uuid import UUID -from sqlalchemy import and_, delete, func, insert, select, update +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pequi.models.community import ( @@ -37,8 +39,6 @@ async def get_or_create_anonymous_id(self, user_id: UUID) -> UUID: O anonymous_id é estável por usuário — o mesmo em todos os posts. """ - from sqlalchemy.exc import IntegrityError - stmt = select(CommunityAnonymousMap.anonymous_id).where( CommunityAnonymousMap.user_id == user_id ) @@ -50,11 +50,10 @@ async def get_or_create_anonymous_id(self, user_id: UUID) -> UUID: # Criar novo mapeamento (tratar race condition) try: - mapping = CommunityAnonymousMap(user_id=user_id) - self._session.add(mapping) - await self._session.flush() - await self._session.refresh(mapping) - return mapping.anonymous_id + async with self._session.begin_nested(): + mapping = CommunityAnonymousMap(user_id=user_id) + self._session.add(mapping) + await self._session.flush() except IntegrityError: # Outra requisição criou o mapeamento, buscar novamente result = await self._session.execute(stmt) @@ -63,6 +62,9 @@ async def get_or_create_anonymous_id(self, user_id: UUID) -> UUID: return existing raise + await self._session.refresh(mapping) + return mapping.anonymous_id + async def deanonymize(self, anonymous_id: UUID) -> CommunityAnonymousMap | None: """Retorna o mapeamento completo (user_id real) — acesso restrito a admin. @@ -89,7 +91,7 @@ async def create_post( author_display_name=author_display_name, title=data.title, content=data.content, - category=data.category, + categories=data.categories, ) self._session.add(post) await self._session.flush() @@ -120,7 +122,7 @@ async def list_posts( if exclude_moderated: filters.append(CommunityPost.is_moderated.is_(False)) if category: - filters.append(CommunityPost.category == category) + filters.append(CommunityPost.categories.overlap([category])) count_stmt = select(func.count()).select_from(CommunityPost).where(*filters) total = (await self._session.execute(count_stmt)).scalar_one() @@ -155,7 +157,10 @@ async def soft_delete_post(self, post_id: UUID) -> CommunityPost | None: """Soft delete de post (próprio autor ou admin).""" stmt = ( update(CommunityPost) - .where(CommunityPost.id == post_id) + .where( + CommunityPost.id == post_id, + CommunityPost.deleted_at.is_(None), + ) .values(deleted_at=datetime.now(UTC)) .returning(CommunityPost) ) @@ -246,24 +251,27 @@ async def add_like( ) -> tuple[bool, int]: """Adiciona like em post — retorna (liked, like_count). - Atômico: incrementa like_count apenas se insert for bem-sucedido. - Levanta IntegrityError se like já existe. + Atômico: incrementa like_count apenas quando um novo like é inserido. """ anonymous_id = await self.get_or_create_anonymous_id(user_id) - # Adicionar like - await self._session.execute( - insert(CommunityLike).values( + result = await self._session.execute( + pg_insert(CommunityLike) + .values( anonymous_id=anonymous_id, post_id=post_id, ) + .on_conflict_do_nothing( + index_elements=["anonymous_id", "post_id"], + ) ) - await self._session.execute( - update(CommunityPost) - .where(CommunityPost.id == post_id) - .values(like_count=CommunityPost.like_count + 1) - ) - await self._session.flush() + if result.rowcount: + await self._session.execute( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(like_count=CommunityPost.like_count + 1) + ) + await self._session.flush() return True, await self._get_post_like_count(post_id) async def remove_like( @@ -274,8 +282,7 @@ async def remove_like( """Remove like em post — retorna (liked, like_count).""" anonymous_id = await self.get_or_create_anonymous_id(user_id) - # Remover like - await self._session.execute( + result = await self._session.execute( delete(CommunityLike).where( and_( CommunityLike.anonymous_id == anonymous_id, @@ -283,12 +290,14 @@ async def remove_like( ) ) ) - await self._session.execute( - update(CommunityPost) - .where(CommunityPost.id == post_id) - .values(like_count=CommunityPost.like_count - 1) - ) - await self._session.flush() + if result.rowcount: + await self._session.execute( + update(CommunityPost) + .where(CommunityPost.id == post_id) + .values(like_count=func.greatest(CommunityPost.like_count - 1, 0)) + ) + await self._session.flush() + return False, await self._get_post_like_count(post_id) async def check_like_exists( @@ -317,7 +326,8 @@ async def _get_author_display_name(self, user_id: UUID, author_mode: str) -> str if author_mode == "anonymous": return None - stmt = select(User.full_name).where( + # Snapshot intencional: posts antigos mantem o username do momento da criacao. + stmt = select(User.username).where( User.id == user_id, User.deleted_at.is_(None), User.is_active.is_(True), @@ -325,13 +335,52 @@ async def _get_author_display_name(self, user_id: UUID, author_mode: str) -> str result = await self._session.execute(stmt) return result.scalar_one_or_none() + async def get_comment_by_id(self, comment_id: UUID) -> CommunityComment | None: + """Retorna comentário por ID — ignora soft-deleted.""" + stmt = select(CommunityComment).where( + CommunityComment.id == comment_id, + CommunityComment.deleted_at.is_(None), + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def soft_delete_comment(self, comment_id: UUID) -> CommunityComment | None: + """Soft delete de comentário e decrementa contador do post.""" + comment = await self.get_comment_by_id(comment_id) + if comment is None: + return None + + stmt = ( + update(CommunityComment) + .where( + CommunityComment.id == comment_id, + CommunityComment.deleted_at.is_(None), + ) + .values(deleted_at=datetime.now(UTC)) + .returning(CommunityComment) + ) + result = await self._session.execute(stmt) + deleted_comment = result.scalar_one_or_none() + if deleted_comment is None: + return None + + await self._session.execute( + update(CommunityPost) + .where(CommunityPost.id == comment.post_id) + .values(comment_count=func.greatest(CommunityPost.comment_count - 1, 0)) + ) + await self._session.flush() + return deleted_comment + async def check_comment_ownership( self, comment_id: UUID, user_id: UUID, ) -> bool: """Verifica se o usuário é dono do comentário via anonymous_id.""" - anonymous_id = await self.get_or_create_anonymous_id(user_id) + anonymous_id = await self.get_anonymous_id(user_id) + if anonymous_id is None: + return False stmt = select(CommunityComment.id).where( and_( CommunityComment.id == comment_id, diff --git a/backend/src/pequi/repositories/patient_repo.py b/backend/src/pequi/repositories/patient_repo.py index 04343fc..f2f84a0 100644 --- a/backend/src/pequi/repositories/patient_repo.py +++ b/backend/src/pequi/repositories/patient_repo.py @@ -1,6 +1,7 @@ from uuid import UUID from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pequi.models.patient import PatientProfile @@ -36,9 +37,17 @@ async def get_or_create_by_user_id(self, user_id: UUID) -> PatientProfile: if patient is not None: return patient - patient = PatientProfile(user_id=user_id) - self.session.add(patient) - await self.session.flush() + try: + async with self.session.begin_nested(): + patient = PatientProfile(user_id=user_id) + self.session.add(patient) + await self.session.flush() + except IntegrityError: + patient = await self.get_by_user_id(user_id) + if patient is not None: + return patient + raise + await self.session.refresh(patient) return patient diff --git a/backend/src/pequi/routers/community.py b/backend/src/pequi/routers/community.py index 819f6c3..a76188e 100644 --- a/backend/src/pequi/routers/community.py +++ b/backend/src/pequi/routers/community.py @@ -26,6 +26,7 @@ from pequi.use_cases.create_comment import CreateCommentUseCase from pequi.use_cases.create_post import CreatePostUseCase from pequi.use_cases.deanonymize import DeanonymizeUseCase +from pequi.use_cases.delete_comment import DeleteCommentUseCase from pequi.use_cases.delete_post import DeletePostUseCase from pequi.use_cases.get_post import GetPostUseCase from pequi.use_cases.list_comments import ListCommentsUseCase @@ -145,6 +146,22 @@ async def list_comments( return await use_case.execute(post_id, limit=limit, offset=offset) +@router.delete("/posts/{post_id}/comments/{comment_id}", response_model=CommentResponse) +@user_limiter.limit("30/hour") +async def delete_comment( + request: Request, + post_id: UUID, + comment_id: UUID, + actor: tuple[UUID, str] = Depends(get_actor_from_token), + session: AsyncSession = Depends(get_db), +) -> CommentResponse: + """Soft delete de comentário — próprio autor ou admin.""" + user_id, role = actor + community_repo, _ = _community_repos(session) + use_case = DeleteCommentUseCase(community_repo) + return await use_case.execute(user_id, post_id, comment_id, is_admin=(role == "admin")) + + @router.post("/posts/{post_id}/like") @user_limiter.limit("60/hour") async def toggle_like( @@ -153,7 +170,7 @@ async def toggle_like( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> dict: - """Toggle like em post — apenas pacientes.""" + """Toggle like em post — adiciona ou remove like do paciente autenticado.""" community_repo, patient_repo = _community_repos(session) use_case = ToggleLikeUseCase(community_repo, patient_repo) return await use_case.execute(user_id, post_id) diff --git a/backend/src/pequi/schemas/community.py b/backend/src/pequi/schemas/community.py index c80c9c1..1ed16df 100644 --- a/backend/src/pequi/schemas/community.py +++ b/backend/src/pequi/schemas/community.py @@ -1,7 +1,9 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator + +POST_CATEGORIES = frozenset({"experience", "question", "support", "news"}) class PostCreate(BaseModel): @@ -11,10 +13,11 @@ class PostCreate(BaseModel): title: str = Field(..., min_length=3, max_length=200, description="Post title") content: str = Field(..., min_length=10, max_length=5000, description="Post content") - category: str = Field( + categories: list[str] = Field( ..., - pattern="^(experience|question|support|news)$", - description="Post category", + min_length=1, + max_length=4, + description="Post categories", ) author_mode: str = Field( ..., @@ -22,6 +25,18 @@ class PostCreate(BaseModel): description="Public author mode", ) + @field_validator("categories") + @classmethod + def validate_categories(cls, value: list[str]) -> list[str]: + if len(set(value)) != len(value): + raise ValueError("Duplicate categories are not allowed") + + invalid = [item for item in value if item not in POST_CATEGORIES] + if invalid: + raise ValueError(f"Invalid categories: {', '.join(invalid)}") + + return value + class CommentCreate(BaseModel): """Payload for creating a community comment.""" @@ -45,7 +60,7 @@ class PostResponse(BaseModel): author_display_name: str | None = None title: str content: str - category: str + categories: list[str] is_pinned: bool is_moderated: bool like_count: int diff --git a/backend/src/pequi/use_cases/delete_comment.py b/backend/src/pequi/use_cases/delete_comment.py new file mode 100644 index 0000000..b05f5e1 --- /dev/null +++ b/backend/src/pequi/use_cases/delete_comment.py @@ -0,0 +1,33 @@ +from uuid import UUID + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.community_repo import CommunityRepository +from pequi.schemas.community import CommentResponse + + +class DeleteCommentUseCase: + def __init__(self, community_repo: CommunityRepository) -> None: + self._community_repo = community_repo + + async def execute( + self, + user_id: UUID, + post_id: UUID, + comment_id: UUID, + is_admin: bool = False, + ) -> CommentResponse: + """Soft delete de comentário (próprio autor ou admin).""" + comment = await self._community_repo.get_comment_by_id(comment_id) + if comment is None or comment.post_id != post_id: + raise NotFoundError("CommunityComment", str(comment_id)) + + if not is_admin: + is_owner = await self._community_repo.check_comment_ownership(comment_id, user_id) + if not is_owner: + raise ForbiddenError("You can only delete your own comments") + + deleted_comment = await self._community_repo.soft_delete_comment(comment_id) + if deleted_comment is None: + raise NotFoundError("CommunityComment", str(comment_id)) + + return CommentResponse.model_validate(deleted_comment) diff --git a/backend/src/pequi/use_cases/export_account_data.py b/backend/src/pequi/use_cases/export_account_data.py index d031d66..785d44c 100644 --- a/backend/src/pequi/use_cases/export_account_data.py +++ b/backend/src/pequi/use_cases/export_account_data.py @@ -222,7 +222,7 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict "id", "title", "content", - "category", + "categories", "is_pinned", "is_moderated", "like_count", diff --git a/backend/src/pequi/use_cases/toggle_like.py b/backend/src/pequi/use_cases/toggle_like.py index a804284..5ab81ac 100644 --- a/backend/src/pequi/use_cases/toggle_like.py +++ b/backend/src/pequi/use_cases/toggle_like.py @@ -1,8 +1,6 @@ from uuid import UUID -from sqlalchemy.exc import IntegrityError - -from pequi.core.exceptions import ConflictError, NotFoundError +from pequi.core.exceptions import NotFoundError from pequi.repositories.community_repo import CommunityRepository from pequi.repositories.patient_repo import PatientRepository @@ -17,21 +15,16 @@ def __init__( self._patient_repo = patient_repo async def execute(self, user_id: UUID, post_id: UUID) -> dict: - """Toggle like em post — retorna (liked, like_count). - - Se já existe like, retorna 409 Conflict (PEQ-108). - Se não existe, cria like e retorna 200. - """ + """Toggle like em post — retorna liked e like_count atualizados.""" await self._patient_repo.get_or_create_by_user_id(user_id) post = await self._community_repo.get_post_by_id(post_id) if post is None: raise NotFoundError("CommunityPost", str(post_id)) - # Criar like (atômico - trata IntegrityError para duplicatas) - try: + if await self._community_repo.check_like_exists(user_id, post_id): + liked, like_count = await self._community_repo.remove_like(user_id, post_id) + else: liked, like_count = await self._community_repo.add_like(user_id, post_id) - except IntegrityError: - raise ConflictError("You already liked this post") from None return {"liked": liked, "like_count": like_count} diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 0d42742..e38bc87 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -82,7 +82,7 @@ async def test_registered_user_can_create_identified_community_post( json={ "title": "Minha experiencia", "content": "Estou compartilhando minha jornada de tratamento.", - "category": "experience", + "categories": ["experience"], "author_mode": "identified", }, ) @@ -90,7 +90,7 @@ async def test_registered_user_can_create_identified_community_post( assert post_response.status_code == 201 post = post_response.json() assert post["author_mode"] == "identified" - assert post["author_display_name"] == "Community Author" + assert post["author_display_name"] == "communityprofile" assert "user_id" not in post diff --git a/backend/tests/integration/test_account_deletion.py b/backend/tests/integration/test_account_deletion.py index 251725b..1fa0d9f 100644 --- a/backend/tests/integration/test_account_deletion.py +++ b/backend/tests/integration/test_account_deletion.py @@ -155,7 +155,7 @@ async def test_delete_account_revokes_token_and_unlinks_anonymous_mapping( author_anonymous_id=mapping.anonymous_id, title="Relato", content="Conteudo publico", - category="experience", + categories=["experience"], ) db_session.add(post) await db_session.flush() diff --git a/backend/tests/integration/test_account_export_and_consents.py b/backend/tests/integration/test_account_export_and_consents.py index e746cb7..9613e5d 100644 --- a/backend/tests/integration/test_account_export_and_consents.py +++ b/backend/tests/integration/test_account_export_and_consents.py @@ -73,7 +73,7 @@ async def test_export_account_data_includes_profile_clinical_community_and_conse author_anonymous_id=mapping.anonymous_id, title="Minha jornada", content="Conteudo publico", - category="experience", + categories=["experience"], ) db_session.add(post) await db_session.flush() @@ -91,6 +91,7 @@ async def test_export_account_data_includes_profile_clinical_community_and_conse assert exported["adherence_snapshots"] == [] assert exported["weekly_symptom_summaries"] == [] assert exported["community_posts"][0]["title"] == "Minha jornada" + assert exported["community_posts"][0]["categories"] == ["experience"] assert "author_anonymous_id" not in exported["community_posts"][0] assert exported["consents"][0]["term_version"] == "v1.2" assert datetime.fromisoformat(exported["exported_at"]).tzinfo is not None diff --git a/backend/tests/integration/test_community_flow.py b/backend/tests/integration/test_community_flow.py index 65904cf..786191b 100644 --- a/backend/tests/integration/test_community_flow.py +++ b/backend/tests/integration/test_community_flow.py @@ -11,6 +11,7 @@ from pequi.use_cases.create_comment import CreateCommentUseCase from pequi.use_cases.create_post import CreatePostUseCase from pequi.use_cases.deanonymize import DeanonymizeUseCase +from pequi.use_cases.delete_comment import DeleteCommentUseCase from pequi.use_cases.delete_post import DeletePostUseCase from pequi.use_cases.get_post import GetPostUseCase from pequi.use_cases.list_comments import ListCommentsUseCase @@ -50,7 +51,7 @@ async def test_patient_creates_post_anonymously(create_tables, db_session): data = PostCreate( title="Minha experiência com o tratamento", content="Estou compartilhando minha jornada...", - category="experience", + categories=["experience"], author_mode="anonymous", ) @@ -72,7 +73,7 @@ async def test_patient_creates_post_anonymously(create_tables, db_session): @pytest.mark.asyncio async def test_patient_creates_identified_post_without_exposing_user_id(create_tables, db_session): - """Identified posts expose chosen display name, never user_id.""" + """Identified posts expose username, never user_id.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="identified@test.com", role="patient") await _create_patient(db_session, user=patient_user, health_unit=health_unit) @@ -80,7 +81,7 @@ async def test_patient_creates_identified_post_without_exposing_user_id(create_t data = PostCreate( title="Minha experiência identificada", content="Quero aparecer com meu nome neste relato.", - category="experience", + categories=["experience"], author_mode="identified", ) @@ -91,7 +92,7 @@ async def test_patient_creates_identified_post_without_exposing_user_id(create_t response_dict = result.model_dump() assert response_dict["author_mode"] == "identified" - assert response_dict["author_display_name"] == patient_user.full_name + assert response_dict["author_display_name"] == patient_user.username assert "user_id" not in response_dict @@ -107,12 +108,12 @@ async def test_anonymous_id_is_stable_for_user(create_tables, db_session): use_case = CreatePostUseCase(community_repo, patient_repo) data1 = PostCreate( - title="Post 1", content="Content 123", category="experience", author_mode="anonymous" + title="Post 1", content="Content 123", categories=["experience"], author_mode="anonymous" ) post1 = await use_case.execute(patient_user.id, data1) data2 = PostCreate( - title="Post 2", content="Content 456", category="question", author_mode="anonymous" + title="Post 2", content="Content 456", categories=["question"], author_mode="anonymous" ) post2 = await use_case.execute(patient_user.id, data2) @@ -134,7 +135,7 @@ async def test_different_users_have_different_anonymous_ids(create_tables, db_se use_case = CreatePostUseCase(community_repo, patient_repo) data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post1 = await use_case.execute(user1.id, data) post2 = await use_case.execute(user2.id, data) @@ -143,6 +144,35 @@ async def test_different_users_have_different_anonymous_ids(create_tables, db_se assert post1.author_anonymous_id != post2.author_anonymous_id +@pytest.mark.asyncio +async def test_list_posts_filters_multi_category_posts(create_tables, db_session): + """Filtering by category finds posts that contain that category in the list.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="multi-category@test.com", role="patient") + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + create_use_case = CreatePostUseCase(community_repo, patient_repo) + + post = await create_use_case.execute( + patient_user.id, + PostCreate( + title="Relato com apoio", + content="Um relato que tambem busca apoio da comunidade.", + categories=["experience", "support"], + author_mode="anonymous", + ), + ) + + list_use_case = ListPostsUseCase(community_repo) + support_posts = await list_use_case.execute(category="support") + + assert support_posts.total == 1 + assert support_posts.items[0].id == post.id + assert support_posts.items[0].categories == ["experience", "support"] + + @pytest.mark.asyncio async def test_user_id_never_appears_in_post_response(create_tables, db_session): """Critical: user_id must never appear in any response field.""" @@ -155,7 +185,7 @@ async def test_user_id_never_appears_in_post_response(create_tables, db_session) use_case = CreatePostUseCase(community_repo, patient_repo) data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) result = await use_case.execute(patient_user.id, data) @@ -177,7 +207,7 @@ async def test_patient_can_comment_on_post(create_tables, db_session): # Create post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -197,10 +227,8 @@ async def test_patient_can_comment_on_post(create_tables, db_session): @pytest.mark.asyncio -async def test_duplicate_like_returns_409_conflict(create_tables, db_session): - """Duplicate like returns 409 Conflict (PEQ-108).""" - from pequi.core.exceptions import ConflictError - +async def test_toggle_like_adds_and_removes(create_tables, db_session): + """Toggle like adds on first call and removes on second call.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient12@test.com", role="patient") await _create_patient(db_session, user=patient_user, health_unit=health_unit) @@ -208,22 +236,50 @@ async def test_duplicate_like_returns_409_conflict(create_tables, db_session): community_repo = CommunityRepository(db_session) patient_repo = PatientRepository(db_session) - # Create post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) - # Like post (should succeed) like_use_case = ToggleLikeUseCase(community_repo, patient_repo) result1 = await like_use_case.execute(patient_user.id, post.id) assert result1["liked"] is True assert result1["like_count"] == 1 - # Try to like again (should return 409 Conflict) - with pytest.raises(ConflictError): - await like_use_case.execute(patient_user.id, post.id) + result2 = await like_use_case.execute(patient_user.id, post.id) + assert result2["liked"] is False + assert result2["like_count"] == 0 + + +@pytest.mark.asyncio +async def test_add_like_duplicate_does_not_increment_count(create_tables, db_session): + """Repository add_like is race-safe for duplicate inserts.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user( + db_session, email="patient-like-race@test.com", role="patient" + ) + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + post = await CreatePostUseCase(community_repo, patient_repo).execute( + patient_user.id, + PostCreate( + title="Test", + content="Test content", + categories=["experience"], + author_mode="anonymous", + ), + ) + + liked, like_count = await community_repo.add_like(patient_user.id, post.id) + duplicate_liked, duplicate_count = await community_repo.add_like(patient_user.id, post.id) + + assert liked is True + assert duplicate_liked is True + assert like_count == 1 + assert duplicate_count == 1 @pytest.mark.asyncio @@ -238,7 +294,7 @@ async def test_list_posts_excludes_moderated_content(create_tables, db_session): # Create two posts post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post1 = await post_use_case.execute(patient_user.id, post_data) @@ -268,7 +324,7 @@ async def test_user_can_delete_own_post(create_tables, db_session): # Create post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -297,7 +353,7 @@ async def test_user_cannot_delete_others_post(create_tables, db_session): # Create post as user1 post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(user1.id, post_data) @@ -308,6 +364,65 @@ async def test_user_cannot_delete_others_post(create_tables, db_session): await delete_use_case.execute(user2.id, post.id) +@pytest.mark.asyncio +async def test_user_can_delete_own_comment(create_tables, db_session): + """User can delete their own comment (soft delete).""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user( + db_session, email="patient-comment-del@test.com", role="patient" + ) + await _create_patient(db_session, user=patient_user, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + post_data = PostCreate( + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" + ) + post = await CreatePostUseCase(community_repo, patient_repo).execute(patient_user.id, post_data) + + comment = await CreateCommentUseCase(community_repo, patient_repo).execute( + patient_user.id, + post.id, + CommentCreate(content="Comentário temporário", author_mode="anonymous"), + ) + + await DeleteCommentUseCase(community_repo).execute(patient_user.id, post.id, comment.id) + + list_use_case = ListCommentsUseCase(community_repo) + result = await list_use_case.execute(post.id) + assert len(result.items) == 0 + + updated_post = await community_repo.get_post_by_id(post.id) + assert updated_post.comment_count == 0 + + +@pytest.mark.asyncio +async def test_user_cannot_delete_others_comment(create_tables, db_session): + """User cannot delete another user's comment.""" + health_unit = await _create_health_unit(db_session) + user1 = await _create_user(db_session, email="comment-user1@test.com", role="patient") + user2 = await _create_user(db_session, email="comment-user2@test.com", role="patient") + await _create_patient(db_session, user=user1, health_unit=health_unit) + await _create_patient(db_session, user=user2, health_unit=health_unit) + + community_repo = CommunityRepository(db_session) + patient_repo = PatientRepository(db_session) + + post_data = PostCreate( + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" + ) + post = await CreatePostUseCase(community_repo, patient_repo).execute(user1.id, post_data) + comment = await CreateCommentUseCase(community_repo, patient_repo).execute( + user1.id, + post.id, + CommentCreate(content="Comentário do user1", author_mode="anonymous"), + ) + + with pytest.raises(ForbiddenError): + await DeleteCommentUseCase(community_repo).execute(user2.id, post.id, comment.id) + + @pytest.mark.asyncio async def test_admin_can_moderate_post(create_tables, db_session): """Admin can moderate posts (audit logged in audit_logs table).""" @@ -327,7 +442,7 @@ async def test_admin_can_moderate_post(create_tables, db_session): # Create post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -371,7 +486,7 @@ async def test_admin_can_deanonymize_with_audit(create_tables, db_session): # Create post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -413,7 +528,7 @@ async def test_soft_deleted_posts_not_visible(create_tables, db_session): # Create and delete post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) @@ -443,7 +558,7 @@ async def test_list_comments_for_post(create_tables, db_session): # Create post post_data = PostCreate( - title="Test", content="Test content", category="experience", author_mode="anonymous" + title="Test", content="Test content", categories=["experience"], author_mode="anonymous" ) post_use_case = CreatePostUseCase(community_repo, patient_repo) post = await post_use_case.execute(patient_user.id, post_data) diff --git a/backend/tests/unit/test_community_anonymization.py b/backend/tests/unit/test_community_anonymization.py index 51be02d..0dddc7a 100644 --- a/backend/tests/unit/test_community_anonymization.py +++ b/backend/tests/unit/test_community_anonymization.py @@ -10,7 +10,7 @@ def test_post_create_title_min_length(): PostCreate( title="ab", content="Valid content", - category="experience", + categories=["experience"], author_mode="anonymous", ) @@ -21,7 +21,7 @@ def test_post_create_title_max_length(): PostCreate( title="a" * 201, content="Valid content", - category="experience", + categories=["experience"], author_mode="anonymous", ) @@ -32,7 +32,7 @@ def test_post_create_content_min_length(): PostCreate( title="Valid title", content="short", - category="experience", + categories=["experience"], author_mode="anonymous", ) @@ -43,41 +43,48 @@ def test_post_create_content_max_length(): PostCreate( title="Valid title", content="a" * 5001, - category="experience", + categories=["experience"], author_mode="anonymous", ) -def test_post_create_category_must_be_valid(): - """Post category must be one of: experience, question, support, news.""" +def test_post_create_categories_must_be_valid(): + """Post categories must be one of: experience, question, support, news.""" with pytest.raises(ValidationError): PostCreate( title="Valid title", content="Valid content", - category="invalid", + categories=["invalid"], author_mode="anonymous", ) - # Valid categories should pass for category in ["experience", "question", "support", "news"]: PostCreate( title="Valid title", content="Valid content", - category=category, + categories=[category], author_mode="anonymous", ) + post = PostCreate( + title="Valid title", + content="Valid content", + categories=["experience", "support"], + author_mode="anonymous", + ) + assert post.categories == ["experience", "support"] + def test_post_create_author_mode_is_required_and_valid(): """User must explicitly choose anonymous or identified posting.""" with pytest.raises(ValidationError): - PostCreate(title="Valid title", content="Valid content", category="experience") + PostCreate(title="Valid title", content="Valid content", categories=["experience"]) with pytest.raises(ValidationError): PostCreate( title="Valid title", content="Valid content", - category="experience", + categories=["experience"], author_mode="invalid", ) @@ -85,7 +92,7 @@ def test_post_create_author_mode_is_required_and_valid(): post = PostCreate( title="Valid title", content="Valid content", - category="experience", + categories=["experience"], author_mode=author_mode, ) assert post.author_mode == author_mode @@ -137,7 +144,7 @@ def test_post_response_never_exposes_user_id(): "author_display_name": None, "title": "Test Post", "content": "Test content", - "category": "experience", + "categories": ["experience"], "is_pinned": False, "is_moderated": False, "like_count": 0, diff --git a/frontend/src/app/features/comunity/community-feed/community-feed.html b/frontend/src/app/features/comunity/community-feed/community-feed.html index 67dd908..59e3acc 100644 --- a/frontend/src/app/features/comunity/community-feed/community-feed.html +++ b/frontend/src/app/features/comunity/community-feed/community-feed.html @@ -41,7 +41,14 @@

Você está na comunid /> - @if (filteredPosts().length === 0) { + @if (postsService.loading()) { +

+ Carregando posts... +

+ } @else if (filteredPosts().length === 0) {

{ let fixture: ComponentFixture; let profileService: CommunityProfileService; let router: Router; + let httpMock: HttpTestingController; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CommunityFeed], + imports: [CommunityFeed, HttpClientTestingModule], providers: [ provideRouter([ { path: 'comunity', component: Comunity }, @@ -27,11 +29,21 @@ describe('CommunityFeed', () => { profileService = TestBed.inject(CommunityProfileService); router = TestBed.inject(Router); + httpMock = TestBed.inject(HttpTestingController); profileService.save('public'); fixture = TestBed.createComponent(CommunityFeed); component = fixture.componentInstance; fixture.detectChanges(); + + httpMock + .expectOne(`${environment.apiUrl}/v1/community/posts?limit=50&offset=0`) + .flush({ items: [], total: 0 }); + fixture.detectChanges(); + }); + + afterEach(() => { + httpMock.verify(); }); it('should create', () => { @@ -39,17 +51,47 @@ describe('CommunityFeed', () => { }); it('should render feed without post detail overlay', () => { - expect(fixture.nativeElement.querySelector('[data-testid="community-feed-list"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="empty-feed"]')).toBeTruthy(); expect(fixture.nativeElement.querySelector('[data-testid="community-post-detail"]')).toBeFalsy(); }); it('should navigate to post page when opening a post', async () => { const navigateSpy = vi.spyOn(router, 'navigate'); - component.openPost('1'); - expect(navigateSpy).toHaveBeenCalledWith(['/comunity/feed', '1']); + component.openPost('post-1'); + expect(navigateSpy).toHaveBeenCalledWith(['/comunity/feed', 'post-1']); }); it('should filter posts by search query', () => { + component.postsService.posts.set([ + { + id: '1', + authorName: 'Ana', + authorInitials: 'AN', + title: 'Formigamento', + description: 'Relato sobre formigamento', + categories: ['relato'], + categoryLabels: ['Relato'], + timeLabel: 'Agora', + supportCount: 0, + isSupported: false, + commentCount: 0, + comments: [], + }, + { + id: '2', + authorName: 'João', + authorInitials: 'JO', + title: 'Outro tema', + description: 'Sem relação', + categories: ['apoio'], + categoryLabels: ['Apoio'], + timeLabel: 'Agora', + supportCount: 0, + isSupported: false, + commentCount: 0, + comments: [], + }, + ]); component.onSearchChange('formigamento'); fixture.detectChanges(); expect(component.filteredPosts().length).toBe(1); @@ -69,16 +111,32 @@ describe('CommunityFeed', () => { }); it('should add post to feed on submit', () => { - const before = component.postsService.posts().length; component.onSubmitPost({ title: 'Post de teste', - description: 'Descrição', - categories: ['relato', 'duvida'], + description: 'Descrição com mais de dez caracteres', + categories: ['relato'], authorMode: 'public', }); + + const req = httpMock.expectOne(`${environment.apiUrl}/v1/community/posts`); + req.flush({ + id: 'new-post', + author_anonymous_id: 'anon-1', + author_mode: 'identified', + author_display_name: 'Teste', + title: 'Post de teste', + content: 'Descrição com mais de dez caracteres', + categories: ['experience'], + is_pinned: false, + is_moderated: false, + like_count: 0, + comment_count: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); fixture.detectChanges(); - expect(component.postsService.posts().length).toBe(before + 1); + expect(component.postsService.posts().length).toBe(1); expect(component.showCreatePost()).toBe(false); expect(component.filteredPosts()[0].title).toBe('Post de teste'); }); diff --git a/frontend/src/app/features/comunity/community-feed/community-feed.ts b/frontend/src/app/features/comunity/community-feed/community-feed.ts index 6a22a8d..8b84d4d 100644 --- a/frontend/src/app/features/comunity/community-feed/community-feed.ts +++ b/frontend/src/app/features/comunity/community-feed/community-feed.ts @@ -1,7 +1,9 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { Router } from '@angular/router'; import { LucideAngularModule, LucideUsers } from 'lucide-angular'; +import { ToastService } from '../../../components/toast/toast.service'; +import { getApiErrorMessage } from '../../../core/api-error.utils'; import { CommunityCreatePost } from '../components/community-create-post/community-create-post'; import { CommunityDeleteConfirm } from '../components/community-delete-confirm/community-delete-confirm'; import { CommunityFab } from '../components/community-fab/community-fab'; @@ -31,8 +33,9 @@ import { CommunityProfileService } from '../services/community-profile.service'; ], templateUrl: './community-feed.html', }) -export class CommunityFeed { +export class CommunityFeed implements OnInit { private readonly router = inject(Router); + private readonly toast = inject(ToastService); readonly profileService = inject(CommunityProfileService); readonly postsService = inject(CommunityPostsService); @@ -49,16 +52,15 @@ export class CommunityFeed { readonly activeFilter = signal('all'); readonly showCreatePost = signal(false); readonly pendingDeletePostId = signal(null); + readonly submitting = signal(false); readonly filteredPosts = computed(() => { const query = this.searchQuery().trim().toLowerCase(); - const filter = this.activeFilter(); + const posts = this.postsService.posts(); - return this.postsService.posts().filter((post) => { - const matchesFilter = filter === 'all' || post.categories.includes(filter); - if (!matchesFilter) return false; - if (!query) return true; + if (!query) return posts; + return posts.filter((post) => { const haystack = [post.title, post.description, post.authorName, ...post.categoryLabels] .join(' ') .toLowerCase(); @@ -72,6 +74,10 @@ export class CommunityFeed { } } + ngOnInit(): void { + this.fetchPosts(); + } + changeProfile(): void { this.profileService.clear(); void this.router.navigate(['/comunity']); @@ -83,10 +89,18 @@ export class CommunityFeed { onFilterChange(filter: CommunityFilterId): void { this.activeFilter.set(filter); + this.fetchPosts(filter); } toggleSupport(postId: string): void { - this.postsService.toggleSupport(postId); + this.postsService.toggleSupport(postId).subscribe({ + error: (error) => { + this.toast.error( + 'Erro ao acolher post', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + }, + }); } openPost(postId: string): void { @@ -102,8 +116,21 @@ export class CommunityFeed { } onSubmitPost(payload: CreatePostFormValue): void { - this.postsService.createPost(payload); - this.showCreatePost.set(false); + this.submitting.set(true); + this.postsService.createPost(payload).subscribe({ + next: () => { + this.submitting.set(false); + this.showCreatePost.set(false); + this.toast.success('Post publicado na comunidade!'); + }, + error: (error) => { + this.submitting.set(false); + this.toast.error( + 'Erro ao publicar post', + getApiErrorMessage(error, 'Revise os campos e tente novamente.'), + ); + }, + }); } requestDeletePost(postId: string): void { @@ -117,7 +144,29 @@ export class CommunityFeed { confirmDeletePost(): void { const postId = this.pendingDeletePostId(); if (!postId) return; - this.postsService.deletePost(postId); - this.pendingDeletePostId.set(null); + + this.postsService.deletePost(postId).subscribe({ + next: () => { + this.pendingDeletePostId.set(null); + this.toast.success('Post excluído.'); + }, + error: (error) => { + this.toast.error( + 'Erro ao excluir post', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + }, + }); + } + + private fetchPosts(filter: CommunityFilterId = this.activeFilter()): void { + this.postsService.loadPosts(filter).subscribe({ + error: (error) => { + this.toast.error( + 'Erro ao carregar comunidade', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + }, + }); } } diff --git a/frontend/src/app/features/comunity/community-post-page/community-post-page.html b/frontend/src/app/features/comunity/community-post-page/community-post-page.html index 154d17f..703ddb1 100644 --- a/frontend/src/app/features/comunity/community-post-page/community-post-page.html +++ b/frontend/src/app/features/comunity/community-post-page/community-post-page.html @@ -1,4 +1,11 @@ -@if (post(); as currentPost) { +@if (loading()) { +

+ Carregando post... +

+} @else if (post(); as currentPost) { params.get('postId'))), - { initialValue: this.route.snapshot.paramMap.get('postId') } + { initialValue: this.route.snapshot.paramMap.get('postId') }, ); readonly post = computed(() => { @@ -32,13 +38,28 @@ export class CommunityPostPage { constructor() { if (!this.profileService.hasProfile()) { void this.router.navigate(['/comunity']); - return; } + } + ngOnInit(): void { const id = this.postId(); - if (id && !this.postsService.getPostById(id)) { + if (!id) { void this.router.navigate(['/comunity/feed']); + return; } + + this.loading.set(true); + this.postsService.loadPostDetail(id).subscribe({ + next: () => this.loading.set(false), + error: (error) => { + this.loading.set(false); + this.toast.error( + 'Erro ao carregar post', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + void this.router.navigate(['/comunity/feed']); + }, + }); } back(): void { @@ -46,23 +67,58 @@ export class CommunityPostPage { } onSupport(postId: string): void { - this.postsService.toggleSupport(postId); + this.postsService.toggleSupport(postId).subscribe({ + error: (error) => { + this.toast.error( + 'Erro ao acolher post', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + }, + }); } - onAddComment(payload: { postId: string; content: string; parentCommentId?: string }): void { - this.postsService.addComment(payload); - } - - onDeleteComment(payload: { + onAddComment(payload: { postId: string; - commentId: string; + content: string; + authorMode: CommunityAuthorMode; parentCommentId?: string; + replyToAuthorName?: string; }): void { - this.postsService.deleteComment(payload); + this.postsService.addComment(payload).subscribe({ + next: () => this.toast.success('Comentário publicado!'), + error: (error) => { + this.toast.error( + 'Erro ao comentar', + getApiErrorMessage(error, 'Revise o texto e tente novamente.'), + ); + }, + }); + } + + onDeleteComment(payload: { postId: string; commentId: string }): void { + this.postsService.deleteComment(payload).subscribe({ + next: () => this.toast.success('Comentário excluído.'), + error: (error) => { + this.toast.error( + 'Erro ao excluir comentário', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + }, + }); } onDeletePost(postId: string): void { - this.postsService.deletePost(postId); - void this.router.navigate(['/comunity/feed']); + this.postsService.deletePost(postId).subscribe({ + next: () => { + this.toast.success('Post excluído.'); + void this.router.navigate(['/comunity/feed']); + }, + error: (error) => { + this.toast.error( + 'Erro ao excluir post', + getApiErrorMessage(error, 'Tente novamente em instantes.'), + ); + }, + }); } } diff --git a/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.html b/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.html index f288e01..2c95555 100644 --- a/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.html +++ b/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.html @@ -10,6 +10,13 @@ [strokeWidth]="2" aria-hidden="true" /> + } @else if (imageUrl()) { + } @else { {{ initials() }} } diff --git a/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.spec.ts b/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.spec.ts index 76c0a4d..ac06dea 100644 --- a/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.spec.ts +++ b/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.spec.ts @@ -31,4 +31,14 @@ describe('CommunityAuthorAvatar', () => { expect(el.querySelector('lucide-icon')).toBeTruthy(); expect(el.textContent?.trim()).not.toContain('VC'); }); + + it('should render profile image when imageUrl is provided', () => { + fixture.componentRef.setInput('anonymous', false); + fixture.componentRef.setInput('imageUrl', 'data:image/png;base64,abc'); + fixture.detectChanges(); + + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('[data-testid="author-avatar-image"]')).toBeTruthy(); + expect(el.textContent?.trim()).not.toContain('VC'); + }); }); diff --git a/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.ts b/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.ts index 6749311..d70a11b 100644 --- a/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.ts +++ b/frontend/src/app/features/comunity/components/community-author-avatar/community-author-avatar.ts @@ -13,6 +13,7 @@ export type CommunityAuthorAvatarSize = 'sm' | 'md'; export class CommunityAuthorAvatar { readonly initials = input.required(); readonly anonymous = input(false); + readonly imageUrl = input(null); readonly size = input('md'); readonly LucideVenetianMask = LucideVenetianMask; @@ -25,7 +26,8 @@ export class CommunityAuthorAvatar { this.size() === 'sm' ? 'h-9 w-9 text-xs' : 'h-10 w-10 text-sm sm:h-11 sm:w-11'; - return `flex shrink-0 items-center justify-center rounded-full font-semibold ${palette} ${dimensions}`; + const overflow = this.imageUrl() ? 'overflow-hidden' : ''; + return `flex shrink-0 items-center justify-center rounded-full font-semibold ${palette} ${dimensions} ${overflow}`; }); readonly iconSize = computed(() => (this.size() === 'sm' ? 16 : 18)); diff --git a/frontend/src/app/features/comunity/components/community-author-mode-picker/community-author-mode-picker.html b/frontend/src/app/features/comunity/components/community-author-mode-picker/community-author-mode-picker.html index 67c042c..b83a367 100644 --- a/frontend/src/app/features/comunity/components/community-author-mode-picker/community-author-mode-picker.html +++ b/frontend/src/app/features/comunity/components/community-author-mode-picker/community-author-mode-picker.html @@ -12,7 +12,7 @@ [attr.data-mode]="option.mode" [attr.aria-pressed]="isSelected(option.mode)" (click)="select(option.mode)" - class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4338CA] sm:px-3.5 sm:text-sm" + class="cursor-pointer inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4338CA] sm:px-3.5 sm:text-sm" [ngClass]=" isSelected(option.mode) ? 'bg-white text-[#4338CA] shadow-sm' diff --git a/frontend/src/app/features/comunity/components/community-create-post/community-create-post.html b/frontend/src/app/features/comunity/components/community-create-post/community-create-post.html index 2b5f5b7..066f96d 100644 --- a/frontend/src/app/features/comunity/components/community-create-post/community-create-post.html +++ b/frontend/src/app/features/comunity/components/community-create-post/community-create-post.html @@ -19,7 +19,7 @@

@@ -91,14 +91,14 @@

Cancelar @@ -26,7 +26,7 @@

type="button" data-testid="confirm-delete" (click)="onConfirm()" - class="flex-1 rounded-xl bg-[#B91C1C] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#B91C1C]" + class="cursor-pointer flex-1 rounded-xl bg-[#B91C1C] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#B91C1C]" > {{ confirmLabel() }} diff --git a/frontend/src/app/features/comunity/components/community-post-card/community-post-card.html b/frontend/src/app/features/comunity/components/community-post-card/community-post-card.html index 7137a9b..19b6ebb 100644 --- a/frontend/src/app/features/comunity/components/community-post-card/community-post-card.html +++ b/frontend/src/app/features/comunity/components/community-post-card/community-post-card.html @@ -12,6 +12,7 @@

{{ post().authorName }}

@@ -31,7 +32,7 @@ type="button" data-testid="delete-post-btn" (click)="onDelete($event)" - class="rounded-full p-1 text-[#B91C1C] transition hover:bg-[#FEE2E2] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#B91C1C]" + class="cursor-pointer rounded-full p-1 text-[#B91C1C] transition hover:bg-[#FEE2E2] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#B91C1C]" aria-label="Excluir post" > diff --git a/frontend/src/app/features/comunity/components/community-post-card/community-post-card.spec.ts b/frontend/src/app/features/comunity/components/community-post-card/community-post-card.spec.ts index 3202c4b..a966b55 100644 --- a/frontend/src/app/features/comunity/components/community-post-card/community-post-card.spec.ts +++ b/frontend/src/app/features/comunity/components/community-post-card/community-post-card.spec.ts @@ -38,7 +38,7 @@ describe('CommunityPostCard', () => { it('should show comment count next to message icon', () => { const count = fixture.nativeElement.querySelector('[data-testid="comment-count"]'); - expect(count?.textContent).toContain('3'); + expect(count?.textContent).toContain('4'); }); it('should mark support button as pressed and fill heart when supported', () => { diff --git a/frontend/src/app/features/comunity/components/community-post-category-picker/community-post-category-picker.html b/frontend/src/app/features/comunity/components/community-post-category-picker/community-post-category-picker.html index 4ed92eb..8812add 100644 --- a/frontend/src/app/features/comunity/components/community-post-category-picker/community-post-category-picker.html +++ b/frontend/src/app/features/comunity/components/community-post-category-picker/community-post-category-picker.html @@ -7,7 +7,7 @@ [attr.data-category]="category.id" [attr.aria-pressed]="isSelected(category.id)" (click)="toggle(category.id)" - class="rounded-full px-4 py-2 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4338CA]" + class="cursor-pointer rounded-full px-4 py-2 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4338CA]" [ngClass]=" isSelected(category.id) ? 'bg-[#CAF9DC] text-[#436D57]' diff --git a/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html b/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html index 6877cec..ba59d24 100644 --- a/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html +++ b/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html @@ -24,6 +24,7 @@

{{ post().authorName }}

@@ -43,7 +44,7 @@

- -
- - -

@@ -200,93 +219,3 @@

- - \ No newline at end of file diff --git a/frontend/src/app/features/medication/medication.spec.ts b/frontend/src/app/features/medication/medication.spec.ts index e622af6..8e8eb0d 100644 --- a/frontend/src/app/features/medication/medication.spec.ts +++ b/frontend/src/app/features/medication/medication.spec.ts @@ -1,51 +1,60 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; import { vi, type Mocked } from 'vitest'; +import { PatientTreatmentService } from '../profile/services/patient-treatment.service'; import { Medication } from './medication'; import { MedicationDataService, type MedicationChecklistResponse, } from './services/medication-data.service'; +import { MedicationIntakeService } from './services/medication-intake.service'; describe('Medication', () => { let component: Medication; let fixture: ComponentFixture; let medicationDataServiceSpy: Mocked; + let intakeService: MedicationIntakeService; const mockResponse: MedicationChecklistResponse = { institutedMedications: [ { - name: 'Suplemento Noturno', - dose: '500', + name: 'Dapsona', + dose: '100', unit: 'mg', - frequency: '08:00 PM', - }, - { - name: 'Vitamina Matinal', - dose: '1', - unit: 'Unidade', - frequency: '08:00 AM', + frequency: '8/8h', }, ], currentDoseMedication: 'Rifampicina + Clofazimina', + treatmentStartDate: '2026-06-04', + canRegisterDoses: false, }; beforeEach(async () => { + localStorage.clear(); + medicationDataServiceSpy = { getMedicationChecklist: vi.fn().mockReturnValue(of(mockResponse)), - saveMedicationAlarm: vi.fn().mockReturnValue(of(undefined)), } as Mocked; await TestBed.configureTestingModule({ imports: [Medication], providers: [ + provideRouter([]), { provide: MedicationDataService, useValue: medicationDataServiceSpy }, + { + provide: PatientTreatmentService, + useValue: { + registerTakenDose: vi.fn().mockReturnValue(of(null)), + }, + }, ], }).compileComponents(); fixture = TestBed.createComponent(Medication); component = fixture.componentInstance; + intakeService = TestBed.inject(MedicationIntakeService); }); it('should create', () => { @@ -57,128 +66,29 @@ describe('Medication', () => { fixture.detectChanges(); expect(medicationDataServiceSpy.getMedicationChecklist).toHaveBeenCalled(); - expect(component.unsupervisedItems.length).toBe(2); - expect(component.supervisedItems.length).toBe(1); - }); - - it('should map unsupervised medications correctly', () => { - fixture.detectChanges(); - - expect(component.unsupervisedItems[0].title).toBe('Suplemento Noturno'); - expect(component.unsupervisedItems[0].subtitle).toBe('500 • mg • 08:00 PM'); - - expect(component.unsupervisedItems[1].title).toBe('Vitamina Matinal'); - expect(component.unsupervisedItems[1].subtitle).toBe('1 • Unidade • 08:00 AM'); - }); - - it('should map supervised medication correctly', () => { - fixture.detectChanges(); - - expect(component.supervisedItems[0].title).toBe('Rifampicina + Clofazimina'); - expect(component.supervisedItems[0].subtitle).toBe(''); - expect(component.supervisedItems[0].checked).toBeFalsy(); + expect(component.unsupervisedItems.length).toBe(3); + expect(component.unsupervisedItems[0].dosesPerDay).toBe(3); + expect(component.unsupervisedItems[0].doseTime).toBe('00:00'); + expect(component.unsupervisedItems[1].doseTime).toBe('08:00'); + expect(component.supervisedItems[0].nextSupervisedDoseLabel).toContain('dose'); }); - it('should toggle unsupervised item', () => { - fixture.detectChanges(); - - const itemId = component.unsupervisedItems[0].id; - - component.toggleUnsupervised(itemId); - expect(component.unsupervisedItems[0].checked).toBeTruthy(); - - component.toggleUnsupervised(itemId); - expect(component.unsupervisedItems[0].checked).toBeFalsy(); - }); + it('persists intake when marking after scheduled time', () => { + const now = new Date(2026, 5, 4, 14, 0, 0); + vi.setSystemTime(now); - it('should toggle supervised item', () => { fixture.detectChanges(); + const item = component.unsupervisedItems[0]; + expect(item.canToggle).toBe(true); - const itemId = component.supervisedItems[0].id; - - component.toggleSupervised(itemId); - expect(component.supervisedItems[0].checked).toBeTruthy(); - - component.toggleSupervised(itemId); - expect(component.supervisedItems[0].checked).toBeFalsy(); - }); - - it('should emit checklist payload after loading data', () => { - const emitSpy = vi.spyOn(component.checklistChange, 'emit'); - - fixture.detectChanges(); - - expect(emitSpy).toHaveBeenCalledWith({ - checkedCount: 0, - totalCount: 3, - unsupervisedCheckedCount: 0, - unsupervisedTotalCount: 2, - supervisedCheckedCount: 0, - supervisedTotalCount: 1, - }); - }); - - it('should emit updated payload when toggling unsupervised item', () => { - fixture.detectChanges(); - const emitSpy = vi.spyOn(component.checklistChange, 'emit'); - - const itemId = component.unsupervisedItems[0].id; - component.toggleUnsupervised(itemId); - - expect(emitSpy).toHaveBeenCalledWith({ - checkedCount: 1, - totalCount: 3, - unsupervisedCheckedCount: 1, - unsupervisedTotalCount: 2, - supervisedCheckedCount: 0, - supervisedTotalCount: 1, - }); - }); - - it('should emit updated payload when toggling supervised item', () => { - fixture.detectChanges(); - const emitSpy = vi.spyOn(component.checklistChange, 'emit'); - - const itemId = component.supervisedItems[0].id; - component.toggleSupervised(itemId); - - expect(emitSpy).toHaveBeenCalledWith({ - checkedCount: 1, - totalCount: 3, - unsupervisedCheckedCount: 0, - unsupervisedTotalCount: 2, - supervisedCheckedCount: 1, - supervisedTotalCount: 1, - }); - }); - - it('should clear lists when service returns error', async () => { - medicationDataServiceSpy.getMedicationChecklist.mockReturnValue( - throwError(() => new Error('erro')) - ); - - fixture = TestBed.createComponent(Medication); - component = fixture.componentInstance; - - fixture.detectChanges(); - - expect(component.unsupervisedItems.length).toBe(0); - expect(component.supervisedItems.length).toBe(0); - expect(component.isLoading).toBeFalsy(); - }); + component.toggleUnsupervised(item.id); + expect(component.unsupervisedItems[0].checked).toBe(true); + expect(intakeService.isSlotTaken(item.storageKey, '2026-06-04_08:00')).toBe(true); - it('should return item id in trackById', () => { - const item = { - id: 'abc123', - title: 'Teste', - subtitle: 'Sub', - doseLabel: '500 mg', - checked: false, - alarmEnabled: false, - alarmConfig: { days: ['monday' as const], time: '08:00' }, - section: 'unsupervised' as const, - }; + component.toggleUnsupervised(item.id); + expect(component.unsupervisedItems[0].checked).toBe(false); + expect(intakeService.isSlotTaken(item.storageKey, '2026-06-04_08:00')).toBe(false); - expect(component.trackById(0, item)).toBe('abc123'); + vi.useRealTimers(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/app/features/medication/medication.ts b/frontend/src/app/features/medication/medication.ts index 8c75c01..898726d 100644 --- a/frontend/src/app/features/medication/medication.ts +++ b/frontend/src/app/features/medication/medication.ts @@ -1,32 +1,25 @@ import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { Component, EventEmitter, OnInit, Output, inject } from '@angular/core'; +import { Component, EventEmitter, OnDestroy, OnInit, Output, effect, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; import type { PatientTreatmentData } from '../profile/models/patient-profile.models'; import { MedicationDataService, type MedicationChecklistResponse, - type MedicationAlarmPayload, - type MedicationAlarmConfig, } from './services/medication-data.service'; +import { MedicationIntakeService } from './services/medication-intake.service'; +import { HealthAppointmentService } from '../appointments/services/health-appointment.service'; +import { PatientTreatmentService } from '../profile/services/patient-treatment.service'; import { - Hospital, - Pill, - Clock3, - Bell, - X, - LucideAngularModule, -} from 'lucide-angular'; + buildMedicationSchedule, + buildTodayDoseSlots, + getDoseSlotIntakeState, + type DoseSlot, +} from './utils/medication-schedule.utils'; +import { formatSupervisedDoseScheduleLabel } from './utils/supervised-dose-schedule.utils'; +import { Hospital, Pill, LucideAngularModule } from 'lucide-angular'; type InstitutedMedicationItem = PatientTreatmentData['institutedMedications'][number]; type MedicationSection = 'unsupervised' | 'supervised'; -type WeekdayKey = - | 'monday' - | 'tuesday' - | 'wednesday' - | 'thursday' - | 'friday' - | 'saturday' - | 'sunday'; export interface MedicationChecklistPayload { checkedCount: number; @@ -37,65 +30,85 @@ export interface MedicationChecklistPayload { supervisedTotalCount: number; } -interface WeekdayOption { - key: WeekdayKey; - label: string; - shortLabel: string; -} - -interface MedicationCardItem { +/** Um card por horário de dose no dia (ex.: 08:00, 16:00, 00:00). */ +interface MedicationDoseCardItem { + storageKey: string; id: string; + medicationName: string; title: string; subtitle: string; + frequencyLabel: string; + doseTime: string; + doseIndex: number; + dosesPerDay: number; + slot: DoseSlot; + isDueNow: boolean; + canToggle: boolean; checked: boolean; + statusLabel: string | null; section: MedicationSection; doseLabel: string; - alarmEnabled: boolean; - alarmConfig: MedicationAlarmConfig; + nextSupervisedDoseLabel: string | null; +} + +interface SupervisedMedicationCardItem { + storageKey: string; + id: string; + title: string; + subtitle: string; + scheduleLabel: string; + doseLabel: string; + nextSupervisedDoseLabel: string | null; } @Component({ selector: 'app-medication', standalone: true, - imports: [CommonModule, LucideAngularModule, FormsModule], + imports: [CommonModule, LucideAngularModule, RouterLink], templateUrl: './medication.html', styleUrl: './medication.css', }) -export class Medication implements OnInit { +export class Medication implements OnInit, OnDestroy { private readonly medicationDataService = inject(MedicationDataService); + private readonly intakeService = inject(MedicationIntakeService); + private readonly treatmentService = inject(PatientTreatmentService); + private readonly appointmentService = inject(HealthAppointmentService); readonly Pill = Pill; readonly Hospital = Hospital; - readonly Clock3 = Clock3; - readonly Bell = Bell; - readonly X = X; @Output() checklistChange = new EventEmitter(); isLoading = false; + canRegisterDoses = false; + loadError = ''; - unsupervisedItems: MedicationCardItem[] = []; - supervisedItems: MedicationCardItem[] = []; + unsupervisedItems: MedicationDoseCardItem[] = []; + supervisedItems: SupervisedMedicationCardItem[] = []; - isAlarmModalOpen = false; - selectedMedicationId: string | null = null; - selectedMedicationSection: MedicationSection | null = null; + private institutedMedications: PatientTreatmentData['institutedMedications'] = []; + private currentDoseMedication = ''; + private treatmentStartDate = ''; + private slotRefreshTimer?: ReturnType; - modalDraftDays: WeekdayKey[] = []; - modalDraftTime = '08:00'; + constructor() { + effect(() => { + this.appointmentService.appointments(); + this.refreshSupervisedSchedule(); + }); + } - readonly weekdays: WeekdayOption[] = [ - { key: 'monday', label: 'Segunda-feira', shortLabel: 'Seg' }, - { key: 'tuesday', label: 'Terça-feira', shortLabel: 'Ter' }, - { key: 'wednesday', label: 'Quarta-feira', shortLabel: 'Qua' }, - { key: 'thursday', label: 'Quinta-feira', shortLabel: 'Qui' }, - { key: 'friday', label: 'Sexta-feira', shortLabel: 'Sex' }, - { key: 'saturday', label: 'Sábado', shortLabel: 'Sáb' }, - { key: 'sunday', label: 'Domingo', shortLabel: 'Dom' }, - ]; + get dueTodayCount(): number { + return this.unsupervisedItems.filter((item) => item.isDueNow).length; + } ngOnInit(): void { this.loadMedicationChecklist(); + this.slotRefreshTimer = setInterval(() => this.refreshUnsupervisedSlots(), 60_000); + } + + ngOnDestroy(): void { + if (this.slotRefreshTimer) clearInterval(this.slotRefreshTimer); } loadMedicationChecklist(): void { @@ -103,11 +116,19 @@ export class Medication implements OnInit { this.medicationDataService.getMedicationChecklist().subscribe({ next: (response: MedicationChecklistResponse) => { - this.unsupervisedItems = this.mapUnsupervisedItems(response.institutedMedications); - this.supervisedItems = this.mapSupervisedItem(response.currentDoseMedication); + this.canRegisterDoses = response.canRegisterDoses; + this.loadError = ''; + this.currentDoseMedication = response.currentDoseMedication; + this.treatmentStartDate = response.treatmentStartDate; + this.institutedMedications = response.institutedMedications; + this.unsupervisedItems = this.mapUnsupervisedItems(this.institutedMedications); + this.refreshSupervisedSchedule(); this.emitChecklistPayload(); }, error: () => { + this.loadError = + 'Não foi possível carregar os medicamentos. Preencha Meu tratamento no perfil.'; + this.institutedMedications = []; this.unsupervisedItems = []; this.supervisedItems = []; this.emitChecklistPayload(); @@ -120,161 +141,117 @@ export class Medication implements OnInit { } toggleUnsupervised(id: string): void { - this.unsupervisedItems = this.unsupervisedItems.map(item => - item.id === id ? { ...item, checked: !item.checked } : item - ); - - this.emitChecklistPayload(); - } + const item = this.unsupervisedItems.find((entry) => entry.id === id); + if (!item?.canToggle) return; + + if (item.checked) { + this.intakeService.unmarkSlot(item.storageKey, item.slot.slotKey); + } else { + this.intakeService.markSlotTaken(item.storageKey, item.slot.slotKey); + this.registerDoseIfAllowed(item.medicationName); + } - toggleSupervised(id: string): void { - this.supervisedItems = this.supervisedItems.map(item => - item.id === id ? { ...item, checked: !item.checked } : item + this.unsupervisedItems = this.unsupervisedItems.map((entry) => + entry.id === id ? this.applySlotState(entry) : entry ); - this.emitChecklistPayload(); } - openAlarmModal(item: MedicationCardItem): void { - this.isAlarmModalOpen = true; - this.selectedMedicationId = item.id; - this.selectedMedicationSection = item.section; - this.modalDraftDays = [...item.alarmConfig.days]; - this.modalDraftTime = item.alarmConfig.time; - } - - closeAlarmModal(): void { - this.isAlarmModalOpen = false; - this.selectedMedicationId = null; - this.selectedMedicationSection = null; - this.modalDraftDays = []; - this.modalDraftTime = '08:00'; + trackById(_: number, item: { id: string }): string { + return item.id; } - toggleModalDay(day: WeekdayKey): void { - const alreadySelected = this.modalDraftDays.includes(day); - - this.modalDraftDays = alreadySelected - ? this.modalDraftDays.filter(selectedDay => selectedDay !== day) - : [...this.modalDraftDays, day]; + private mapUnsupervisedItems( + items: PatientTreatmentData['institutedMedications'] + ): MedicationDoseCardItem[] { + return items.flatMap((item: InstitutedMedicationItem) => { + const schedule = buildMedicationSchedule(item.frequency); + const storageKey = this.intakeService.medicationKey(item.name); + const slots = buildTodayDoseSlots(schedule.reminderTimes); + const dosesPerDay = slots.length; + const frequencyLabel = schedule.label.split(' · ')[0] ?? schedule.label; + const subtitle = this.buildSubtitle(item.dose, item.unit); + + return slots.map((slot, index) => { + const base: MedicationDoseCardItem = { + storageKey, + id: `${storageKey}_${slot.time}`, + medicationName: item.name, + title: item.name, + subtitle, + frequencyLabel, + doseTime: slot.time, + doseIndex: index + 1, + dosesPerDay, + slot, + isDueNow: false, + canToggle: false, + checked: false, + statusLabel: null, + section: 'unsupervised', + doseLabel: this.buildDoseLabel(item.dose, item.unit), + nextSupervisedDoseLabel: null, + }; + + return this.applySlotState(base); + }); + }); } - saveAlarmConfig(): void { - const item = this.getSelectedMedicationItem(); - - if (!item) { - return; - } - - const normalizedDays = this.modalDraftDays.length - ? [...this.modalDraftDays] - : this.weekdays.map(day => day.key); + private applySlotState(item: MedicationDoseCardItem): MedicationDoseCardItem { + const taken = this.intakeService.isSlotTaken(item.storageKey, item.slot.slotKey); + const state = getDoseSlotIntakeState(item.slot, taken); - const updatedItem: MedicationCardItem = { + return { ...item, - alarmEnabled: true, - alarmConfig: { - days: normalizedDays, - time: this.modalDraftTime || '08:00', - }, + checked: state.checked, + canToggle: state.canToggle, + isDueNow: state.isDueNow, + statusLabel: state.statusLabel, }; - - this.updateMedicationItem(updatedItem); - - const payload: MedicationAlarmPayload = { - medicationName: updatedItem.title, - dosage: updatedItem.doseLabel, - message: `Está na hora de tomar o remédio ${updatedItem.title}.`, - schedule: { - days: updatedItem.alarmConfig.days, - time: updatedItem.alarmConfig.time, - }, - }; - - this.medicationDataService.saveMedicationAlarm(payload).subscribe(); - this.closeAlarmModal(); - } - - getSelectedMedicationName(): string { - return this.getSelectedMedicationItem()?.title ?? ''; - } - - isDaySelected(day: WeekdayKey): boolean { - return this.modalDraftDays.includes(day); - } - - trackById(_: number, item: MedicationCardItem): string { - return item.id; } - private getSelectedMedicationItem(): MedicationCardItem | null { - if (!this.selectedMedicationId || !this.selectedMedicationSection) { - return null; - } - - const source = - this.selectedMedicationSection === 'unsupervised' - ? this.unsupervisedItems - : this.supervisedItems; - - return source.find(item => item.id === this.selectedMedicationId) ?? null; - } - - private updateMedicationItem(updatedItem: MedicationCardItem): void { - if (updatedItem.section === 'unsupervised') { - this.unsupervisedItems = this.unsupervisedItems.map(item => - item.id === updatedItem.id ? updatedItem : item - ); - return; - } - - this.supervisedItems = this.supervisedItems.map(item => - item.id === updatedItem.id ? updatedItem : item + private refreshSupervisedSchedule(): void { + this.supervisedItems = this.mapSupervisedItems( + this.currentDoseMedication, + this.treatmentStartDate ); } - private mapUnsupervisedItems( - items: PatientTreatmentData['institutedMedications'] - ): MedicationCardItem[] { - return items.map((item: InstitutedMedicationItem) => { - const doseLabel = this.buildDoseLabel(item.dose, item.unit); - - return { - id: crypto.randomUUID(), - title: item.name, - subtitle: this.buildSubtitle(item.dose, item.unit, item.frequency), - checked: false, - section: 'unsupervised', - doseLabel, - alarmEnabled: true, - alarmConfig: this.buildDefaultAlarmConfig(item.frequency), - }; - }); + private refreshUnsupervisedSlots(): void { + if (this.institutedMedications.length === 0) return; + this.unsupervisedItems = this.mapUnsupervisedItems(this.institutedMedications); + this.emitChecklistPayload(); } - private mapSupervisedItem( - value: PatientTreatmentData['currentDoseMedication'] - ): MedicationCardItem[] { + private mapSupervisedItems( + value: PatientTreatmentData['currentDoseMedication'], + treatmentStartDate: string + ): SupervisedMedicationCardItem[] { const trimmed = value.trim(); - if (!trimmed) return []; + const storageKey = this.intakeService.medicationKey(`supervised:${trimmed}`); + const lastSupervisedDoseDate = this.appointmentService.getLastSupervisedDoseDate(); + return [ { - id: crypto.randomUUID(), + storageKey, + id: storageKey, title: trimmed, subtitle: '', - checked: false, - section: 'supervised', + scheduleLabel: '1 vez por mês · na unidade de saúde', doseLabel: 'Dose supervisionada', - alarmEnabled: true, - alarmConfig: this.buildDefaultAlarmConfig(), + nextSupervisedDoseLabel: formatSupervisedDoseScheduleLabel( + treatmentStartDate, + lastSupervisedDoseDate + ), }, ]; } - private buildSubtitle(dose: string, unit: string, frequency: string): string { - const parts = [dose?.trim(), unit?.trim(), frequency?.trim()].filter(Boolean); + private buildSubtitle(dose: string, unit: string): string { + const parts = [dose?.trim(), unit?.trim()].filter(Boolean); return parts.join(' • '); } @@ -283,55 +260,22 @@ export class Medication implements OnInit { return parts.join(' '); } - private buildDefaultAlarmConfig(frequency?: string): MedicationAlarmConfig { - return { - days: this.weekdays.map(day => day.key), - time: this.extractTimeFromFrequency(frequency), - }; - } - - private extractTimeFromFrequency(frequency?: string): string { - const value = frequency?.trim(); - - if (!value) { - return '08:00'; - } - - const match = value.match(/(\d{1,2}):(\d{2})\s?(AM|PM)/i); - - if (!match) { - return '08:00'; - } - - const [, hourRaw, minute, periodRaw] = match; - const period = periodRaw.toUpperCase(); - let hour = Number(hourRaw); - - if (period === 'AM' && hour === 12) { - hour = 0; - } - - if (period === 'PM' && hour < 12) { - hour += 12; - } - - return `${String(hour).padStart(2, '0')}:${minute}`; + private registerDoseIfAllowed(drugName: string): void { + if (!this.canRegisterDoses) return; + this.treatmentService.registerTakenDose(drugName).subscribe({ error: () => undefined }); } private emitChecklistPayload(): void { - const unsupervisedCheckedCount = this.unsupervisedItems.filter(item => item.checked).length; + const unsupervisedCheckedCount = this.unsupervisedItems.filter((item) => item.checked).length; const unsupervisedTotalCount = this.unsupervisedItems.length; - const supervisedCheckedCount = this.supervisedItems.filter(item => item.checked).length; - const supervisedTotalCount = this.supervisedItems.length; - this.checklistChange.emit({ - checkedCount: unsupervisedCheckedCount + supervisedCheckedCount, - totalCount: unsupervisedTotalCount + supervisedTotalCount, + checkedCount: unsupervisedCheckedCount, + totalCount: unsupervisedTotalCount, unsupervisedCheckedCount, unsupervisedTotalCount, - supervisedCheckedCount, - supervisedTotalCount, + supervisedCheckedCount: 0, + supervisedTotalCount: this.supervisedItems.length, }); } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/medication/services/medication-data.service.ts b/frontend/src/app/features/medication/services/medication-data.service.ts index 2575f0b..f894b8e 100644 --- a/frontend/src/app/features/medication/services/medication-data.service.ts +++ b/frontend/src/app/features/medication/services/medication-data.service.ts @@ -1,6 +1,11 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + import type { PatientTreatmentData } from '../../profile/models/patient-profile.models'; +import { AuthService } from '../../auth/services/auth-service'; +import { PatientProfileService } from '../../profile/services/patient-profile.service'; +import { PatientTreatmentService } from '../../profile/services/patient-treatment.service'; export type WeekdayKey = | 'monday' @@ -11,51 +16,49 @@ export type WeekdayKey = | 'saturday' | 'sunday'; -export interface MedicationAlarmConfig { - days: WeekdayKey[]; - time: string; -} - -export interface MedicationAlarmPayload { - medicationName: string; - dosage: string; - message: string; - schedule: MedicationAlarmConfig; -} - export interface MedicationChecklistResponse { institutedMedications: PatientTreatmentData['institutedMedications']; currentDoseMedication: PatientTreatmentData['currentDoseMedication']; + treatmentStartDate: PatientTreatmentData['treatmentStartDate']; + canRegisterDoses: boolean; } @Injectable({ providedIn: 'root', }) export class MedicationDataService { + private readonly authService = inject(AuthService); + private readonly treatmentApi = inject(PatientTreatmentService); + private readonly profileService = inject(PatientProfileService); + getMedicationChecklist(): Observable { - const mockResponse: MedicationChecklistResponse = { - institutedMedications: [ - { - name: 'Suplemento Noturno', - dose: '500', - unit: 'mg', - frequency: '08:00 PM', - }, - { - name: 'Vitamina Matinal', - dose: '1', - unit: 'Unidade', - frequency: '08:00 AM', - }, - ], - currentDoseMedication: 'Rifampicina + Clofazimina', - }; + const local = this.profileService.profile().treatment; + if (!this.authService.isAuthenticated()) { + return of(this.fromLocalTreatment(local)); + } - return of(mockResponse); + return this.treatmentApi.getMedicationChecklist().pipe( + map((api) => ({ + institutedMedications: api.instituted_medications.map((item) => ({ + name: item.name, + dose: item.dose ?? '', + unit: item.unit ?? 'mg', + frequency: item.frequency ?? 'dia', + })), + currentDoseMedication: api.current_dose_medication ?? '', + treatmentStartDate: api.treatment_start_date ?? local.treatmentStartDate ?? '', + canRegisterDoses: api.can_register_doses, + })), + catchError(() => of(this.fromLocalTreatment(this.profileService.profile().treatment))), + ); } - saveMedicationAlarm(payload: MedicationAlarmPayload): Observable { - console.log('Payload de alarme da medicação:', payload); - return of(payload); + private fromLocalTreatment(treatment: PatientTreatmentData): MedicationChecklistResponse { + return { + institutedMedications: treatment.institutedMedications ?? [], + currentDoseMedication: treatment.currentDoseMedication ?? '', + treatmentStartDate: treatment.treatmentStartDate ?? '', + canRegisterDoses: !!this.treatmentApi.activeTreatmentId(), + }; } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/medication/services/medication-intake.service.ts b/frontend/src/app/features/medication/services/medication-intake.service.ts new file mode 100644 index 0000000..6ee9d84 --- /dev/null +++ b/frontend/src/app/features/medication/services/medication-intake.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@angular/core'; + +const STORAGE_KEY = 'pequi.medication_intakes'; + +/** slotKey = YYYY-MM-DD_HH:mm (horário previsto do dia). */ +export type MedicationIntakeLog = Record; + +@Injectable({ providedIn: 'root' }) +export class MedicationIntakeService { + medicationKey(name: string): string { + return name + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, '-'); + } + + getTakenSlotKeys(medicationKey: string): string[] { + const log = this.loadLog(); + return log[medicationKey] ?? []; + } + + isSlotTaken(medicationKey: string, slotKey: string): boolean { + return this.getTakenSlotKeys(medicationKey).includes(slotKey); + } + + markSlotTaken(medicationKey: string, slotKey: string): void { + const log = this.loadLog(); + const slots = new Set(log[medicationKey] ?? []); + slots.add(slotKey); + log[medicationKey] = [...slots]; + this.persist(log); + } + + unmarkSlot(medicationKey: string, slotKey: string): void { + const log = this.loadLog(); + const next = (log[medicationKey] ?? []).filter((key) => key !== slotKey); + if (next.length === 0) { + delete log[medicationKey]; + } else { + log[medicationKey] = next; + } + this.persist(log); + } + + countTakenToday(medicationKey: string): number { + const today = formatLocalDate(new Date()); + return this.getTakenSlotKeys(medicationKey).filter((key) => key.startsWith(`${today}_`)).length; + } + + private loadLog(): MedicationIntakeLog { + if (typeof localStorage === 'undefined') return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as MedicationIntakeLog; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + + private persist(log: MedicationIntakeLog): void { + if (typeof localStorage === 'undefined') return; + const pruned = this.pruneOldEntries(log); + localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned)); + } + + private pruneOldEntries(log: MedicationIntakeLog): MedicationIntakeLog { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 30); + const cutoffKey = formatLocalDate(cutoff); + + const next: MedicationIntakeLog = {}; + for (const [medKey, slots] of Object.entries(log)) { + const kept = slots.filter((slotKey) => { + const datePart = slotKey.split('_')[0]; + return datePart >= cutoffKey; + }); + if (kept.length > 0) { + next[medKey] = kept; + } + } + return next; + } +} + +export function formatLocalDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} diff --git a/frontend/src/app/features/medication/utils/medication-schedule.utils.spec.ts b/frontend/src/app/features/medication/utils/medication-schedule.utils.spec.ts new file mode 100644 index 0000000..e81b822 --- /dev/null +++ b/frontend/src/app/features/medication/utils/medication-schedule.utils.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + allTodayDoseSlotsTaken, + buildMedicationSchedule, + buildTodayDoseSlots, + getDoseSlotIntakeState, + getNextDoseTimeLabel, + getLastTakenDoseSlotToday, + getPendingDoseSlot, + isMedicationDueNow, +} from './medication-schedule.utils'; + +describe('medication-schedule.utils', () => { + it('builds 8/8h schedule with three reminder times', () => { + const schedule = buildMedicationSchedule('8/8h'); + expect(schedule.reminderTimes).toEqual(['00:00', '08:00', '16:00']); + expect(schedule.label).toContain('A cada 8 horas'); + expect(buildTodayDoseSlots(schedule.reminderTimes)).toHaveLength(3); + }); + + it('exposes per-slot intake state before and after scheduled time', () => { + const slots = buildTodayDoseSlots(['08:00', '16:00'], new Date(2026, 5, 4, 12, 0, 0)); + const morning = getDoseSlotIntakeState(slots[0], false, new Date(2026, 5, 4, 12, 0, 0)); + const afternoon = getDoseSlotIntakeState(slots[1], false, new Date(2026, 5, 4, 12, 0, 0)); + + expect(morning.canToggle).toBe(true); + expect(afternoon.statusLabel).toBe('Horário: 16:00'); + expect(afternoon.canToggle).toBe(false); + }); + + it('returns pending slot after scheduled time even outside 2h window', () => { + const now = new Date(2026, 5, 4, 20, 30, 0); + const slot = getPendingDoseSlot(['08:00', '16:00'], () => false, now); + expect(slot?.time).toBe('08:00'); + expect(slot?.slotKey).toBe('2026-06-04_08:00'); + }); + + it('skips taken slots and returns next pending', () => { + const now = new Date(2026, 5, 4, 20, 30, 0); + const taken = new Set(['2026-06-04_08:00']); + const slot = getPendingDoseSlot(['08:00', '16:00'], (key) => taken.has(key), now); + expect(slot?.time).toBe('16:00'); + }); + + it('marks urgent only within 2h after scheduled time', () => { + const slot = { slotKey: '2026-06-04_08:00', time: '08:00', dateKey: '2026-06-04' }; + const inside = new Date(2026, 5, 4, 9, 0, 0); + const outside = new Date(2026, 5, 4, 12, 0, 0); + expect(isMedicationDueNow(slot, false, inside)).toBe(true); + expect(isMedicationDueNow(slot, false, outside)).toBe(false); + }); + + it('returns last taken slot for unmarking', () => { + const now = new Date(2026, 5, 4, 20, 30, 0); + const taken = new Set(['2026-06-04_08:00', '2026-06-04_16:00']); + const slot = getLastTakenDoseSlotToday(['08:00', '16:00'], (key) => taken.has(key), now); + expect(slot?.time).toBe('16:00'); + }); + + it('detects when all daily slots are taken', () => { + const taken = new Set(['2026-06-04_08:00']); + expect(allTodayDoseSlotsTaken(['08:00'], (key) => taken.has(key))).toBe(true); + }); + + it('returns next dose time when before first slot', () => { + const now = new Date(2026, 5, 4, 7, 0, 0); + expect(getNextDoseTimeLabel(['08:00', '16:00'], now)).toBe('08:00'); + }); +}); diff --git a/frontend/src/app/features/medication/utils/medication-schedule.utils.ts b/frontend/src/app/features/medication/utils/medication-schedule.utils.ts new file mode 100644 index 0000000..2776d8f --- /dev/null +++ b/frontend/src/app/features/medication/utils/medication-schedule.utils.ts @@ -0,0 +1,255 @@ +export interface MedicationScheduleInfo { + frequencyKey: string; + label: string; + reminderTimes: string[]; +} + +const FREQUENCY_LABELS: Record = { + dia: '1 vez ao dia', + '12/12h': 'A cada 12 horas', + '8/8h': 'A cada 8 horas', + '6/6h': 'A cada 6 horas', + semana: '1 vez por semana', + quinzena: 'A cada 15 dias', + mes: '1 vez por mês', +}; + +export function normalizeFrequency(frequency?: string): string { + const value = frequency?.trim().toLowerCase() ?? ''; + if (!value) return 'dia'; + if (value === 'mês' || value === 'mes') return 'mes'; + if (value in FREQUENCY_LABELS) return value; + return 'dia'; +} + +export function extractTimeFromFrequency(frequency?: string): string { + const value = frequency?.trim(); + if (!value) return '08:00'; + + const match = value.match(/(\d{1,2}):(\d{2})\s?(AM|PM)/i); + if (!match) return '08:00'; + + const [, hourRaw, minute, periodRaw] = match; + const period = periodRaw.toUpperCase(); + let hour = Number(hourRaw); + + if (period === 'AM' && hour === 12) hour = 0; + if (period === 'PM' && hour < 12) hour += 12; + + return `${String(hour).padStart(2, '0')}:${minute}`; +} + +export function buildMedicationSchedule(frequency?: string): MedicationScheduleInfo { + const frequencyKey = normalizeFrequency(frequency); + const baseTime = extractTimeFromFrequency(frequency); + const reminderTimes = timesForFrequency(frequencyKey, baseTime); + const label = formatScheduleLabel(frequencyKey, reminderTimes); + + return { frequencyKey, label, reminderTimes }; +} + +export interface DoseSlot { + slotKey: string; + time: string; + dateKey: string; +} + +const SLOT_URGENT_WINDOW_MINUTES = 120; + +function timeToMinutes(time: string): number { + const [hour, minute] = time.split(':').map(Number); + return hour * 60 + minute; +} + +function sortReminderTimes(times: string[]): string[] { + return [...times].sort((a, b) => timeToMinutes(a) - timeToMinutes(b)); +} + +/** + * Primeiro horário do dia que já passou e ainda não foi marcado como tomado. + * Permite marcar a dose depois do horário (ex.: 08:00 marcável até o fim do dia). + */ +export function getPendingDoseSlot( + reminderTimes: string[], + isSlotTaken: (slotKey: string) => boolean, + now = new Date() +): DoseSlot | null { + if (reminderTimes.length === 0) return null; + + const dateKey = formatLocalDateKey(now); + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + + for (const time of sortReminderTimes(reminderTimes)) { + if (timeToMinutes(time) > currentMinutes) break; + + const slotKey = `${dateKey}_${time}`; + if (!isSlotTaken(slotKey)) { + return { slotKey, time, dateKey }; + } + } + + return null; +} + +/** Último horário do dia já passado que foi marcado como tomado (para desmarcar). */ +export function getLastTakenDoseSlotToday( + reminderTimes: string[], + isSlotTaken: (slotKey: string) => boolean, + now = new Date() +): DoseSlot | null { + if (reminderTimes.length === 0) return null; + + const dateKey = formatLocalDateKey(now); + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + let lastTaken: DoseSlot | null = null; + + for (const time of sortReminderTimes(reminderTimes)) { + if (timeToMinutes(time) > currentMinutes) break; + + const slotKey = `${dateKey}_${time}`; + if (isSlotTaken(slotKey)) { + lastTaken = { slotKey, time, dateKey }; + } + } + + return lastTaken; +} + +/** @deprecated Use getPendingDoseSlot — mantido para testes legados. */ +export function getActiveDoseSlot(reminderTimes: string[], now = new Date()): DoseSlot | null { + return getPendingDoseSlot(reminderTimes, () => false, now); +} + +/** Próximo horário do dia ainda não passado (para exibir quando não está na janela). */ +export function getNextDoseTimeLabel(reminderTimes: string[], now = new Date()): string | null { + if (reminderTimes.length === 0) return null; + + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + for (const time of reminderTimes) { + const [hour, minute] = time.split(':').map(Number); + if (hour * 60 + minute > currentMinutes) { + return time; + } + } + + return reminderTimes[0] ?? null; +} + +export function isMedicationDueNow( + pendingSlot: DoseSlot | null, + slotTaken: boolean, + now = new Date() +): boolean { + if (!pendingSlot || slotTaken) return false; + + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const start = timeToMinutes(pendingSlot.time); + return currentMinutes >= start && currentMinutes < start + SLOT_URGENT_WINDOW_MINUTES; +} + +export function allTodayDoseSlotsTaken( + reminderTimes: string[], + isSlotTaken: (slotKey: string) => boolean, + now = new Date() +): boolean { + if (reminderTimes.length === 0) return false; + + const dateKey = formatLocalDateKey(now); + return sortReminderTimes(reminderTimes).every((time) => + isSlotTaken(`${dateKey}_${time}`) + ); +} + +export function buildTodayDoseSlots(reminderTimes: string[], now = new Date()): DoseSlot[] { + const dateKey = formatLocalDateKey(now); + return sortReminderTimes(reminderTimes).map((time) => ({ + slotKey: `${dateKey}_${time}`, + time, + dateKey, + })); +} + +export interface DoseSlotIntakeState { + checked: boolean; + canToggle: boolean; + isDueNow: boolean; + statusLabel: string | null; +} + +export function getDoseSlotIntakeState( + slot: DoseSlot, + isSlotTaken: boolean, + now = new Date() +): DoseSlotIntakeState { + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const slotMinutes = timeToMinutes(slot.time); + const hasPassed = slotMinutes <= currentMinutes; + + if (!hasPassed) { + return { + checked: false, + canToggle: false, + isDueNow: false, + statusLabel: `Horário: ${slot.time}`, + }; + } + + if (isSlotTaken) { + return { + checked: true, + canToggle: true, + isDueNow: false, + statusLabel: 'Tomado neste horário', + }; + } + + const isDueNow = + currentMinutes >= slotMinutes && + currentMinutes < slotMinutes + SLOT_URGENT_WINDOW_MINUTES; + + return { + checked: false, + canToggle: true, + isDueNow, + statusLabel: isDueNow ? null : 'Pendente — pode marcar até o fim do dia', + }; +} + +export function formatLocalDateKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function timesForFrequency(key: string, baseTime: string): string[] { + switch (key) { + case '12/12h': + return shiftFromBase(baseTime, [0, 12]); + case '8/8h': + return shiftFromBase(baseTime, [0, 8, 16]); + case '6/6h': + return shiftFromBase(baseTime, [0, 6, 12, 18]); + case 'semana': + case 'quinzena': + case 'mes': + return [baseTime]; + default: + return [baseTime]; + } +} + +function shiftFromBase(baseTime: string, offsetsHours: number[]): string[] { + const [baseHour, baseMinute] = baseTime.split(':').map(Number); + const unique = new Set(); + for (const offset of offsetsHours) { + const total = (baseHour + offset) % 24; + unique.add(`${String(total).padStart(2, '0')}:${String(baseMinute).padStart(2, '0')}`); + } + return [...unique].sort(); +} + +function formatScheduleLabel(key: string, times: string[]): string { + const base = FREQUENCY_LABELS[key] ?? FREQUENCY_LABELS['dia']; + return `${base} · horários: ${times.join(', ')}`; +} diff --git a/frontend/src/app/features/medication/utils/supervised-dose-schedule.utils.spec.ts b/frontend/src/app/features/medication/utils/supervised-dose-schedule.utils.spec.ts new file mode 100644 index 0000000..68c0c73 --- /dev/null +++ b/frontend/src/app/features/medication/utils/supervised-dose-schedule.utils.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatSupervisedDoseScheduleLabel, + getNextSupervisedDoseDate, +} from './supervised-dose-schedule.utils'; + +describe('supervised-dose-schedule.utils', () => { + it('returns start date when treatment has not begun yet', () => { + const now = new Date(2026, 5, 1, 12, 0, 0); + const next = getNextSupervisedDoseDate('2026-06-15', null, now); + expect(next?.getDate()).toBe(15); + expect(next?.getMonth()).toBe(5); + }); + + it('returns same month day when still before next monthly cycle', () => { + const now = new Date(2026, 5, 4, 12, 0, 0); + const next = getNextSupervisedDoseDate('2026-06-04', null, now); + expect(next?.getDate()).toBe(4); + expect(next?.getMonth()).toBe(5); + }); + + it('advances to next month after dose day passed', () => { + const now = new Date(2026, 5, 10, 12, 0, 0); + const next = getNextSupervisedDoseDate('2026-06-04', null, now); + expect(next?.getDate()).toBe(4); + expect(next?.getMonth()).toBe(6); + }); + + it('uses last supervised dose from consultation for next month', () => { + const now = new Date(2026, 5, 10, 12, 0, 0); + const next = getNextSupervisedDoseDate('2026-01-04', '2026-06-04', now); + expect(next?.getDate()).toBe(4); + expect(next?.getMonth()).toBe(6); + }); + + it('labels next dose one month after consultation dose', () => { + const now = new Date(2026, 5, 5, 9, 0, 0); + expect(formatSupervisedDoseScheduleLabel('2026-01-04', '2026-06-04', now)).toContain( + '04/07/2026' + ); + }); + + it('labels today when next monthly dose is today', () => { + const now = new Date(2026, 6, 4, 9, 0, 0); + expect(formatSupervisedDoseScheduleLabel('2026-01-04', '2026-06-04', now)).toContain('hoje'); + }); +}); diff --git a/frontend/src/app/features/medication/utils/supervised-dose-schedule.utils.ts b/frontend/src/app/features/medication/utils/supervised-dose-schedule.utils.ts new file mode 100644 index 0000000..5e30c4d --- /dev/null +++ b/frontend/src/app/features/medication/utils/supervised-dose-schedule.utils.ts @@ -0,0 +1,104 @@ +function parseLocalDate(value: string): Date | null { + const trimmed = value?.trim() ?? ''; + const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const parsed = new Date(year, month, day); + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month || + parsed.getDate() !== day + ) { + return null; + } + return parsed; +} + +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function addCalendarMonths(date: Date, months: number): Date { + const day = date.getDate(); + const result = new Date(date.getFullYear(), date.getMonth() + months, day); + if (result.getDate() !== day) { + return new Date(result.getFullYear(), result.getMonth() + 1, 0); + } + return result; +} + +/** Próxima data de dose supervisionada (mensal). */ +export function getNextSupervisedDoseDate( + treatmentStartDate: string, + lastSupervisedDoseDate?: string | null, + now = new Date() +): Date | null { + const today = startOfLocalDay(now); + const lastTaken = lastSupervisedDoseDate?.trim() + ? parseLocalDate(lastSupervisedDoseDate) + : null; + + if (lastTaken) { + let next = addCalendarMonths(startOfLocalDay(lastTaken), 1); + while (next.getTime() < today.getTime()) { + next = addCalendarMonths(next, 1); + } + return next; + } + + const start = parseLocalDate(treatmentStartDate); + if (!start) return null; + + let candidate = startOfLocalDay(start); + if (today.getTime() < candidate.getTime()) { + return candidate; + } + + while (candidate.getTime() < today.getTime()) { + candidate = addCalendarMonths(candidate, 1); + } + + return candidate; +} + +export function formatSupervisedDoseDate(date: Date): string { + return date.toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +export function formatSupervisedDoseScheduleLabel( + treatmentStartDate: string, + lastSupervisedDoseDate?: string | null, + now = new Date() +): string { + const hasStart = Boolean(treatmentStartDate?.trim()); + const hasLastDose = Boolean(lastSupervisedDoseDate?.trim()); + + if (!hasStart && !hasLastDose) { + return 'Informe a data de início em Meu tratamento para ver o próximo dia da dose.'; + } + + const next = getNextSupervisedDoseDate( + treatmentStartDate, + lastSupervisedDoseDate, + now + ); + if (!next) { + return 'Data de início do tratamento inválida. Atualize em Meu tratamento.'; + } + + const formatted = formatSupervisedDoseDate(next); + const isToday = startOfLocalDay(next).getTime() === startOfLocalDay(now).getTime(); + + if (isToday) { + return `Dia da dose supervisionada: hoje (${formatted})`; + } + + return `Próximo dia da dose: ${formatted}`; +} diff --git a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html index 31202fc..6db3e24 100644 --- a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html +++ b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.html @@ -13,7 +13,7 @@ >

- E-mail e senha + Senha de acesso

diff --git a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts index 659d972..1dd4251 100644 --- a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts +++ b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.spec.ts @@ -1,31 +1,63 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; +import { ToastService } from '../../../../components/toast/toast.service'; +import { PatientProfileService } from '../../services/patient-profile.service'; import { ProfileEditAccount } from './profile-edit-account'; describe('ProfileEditAccount', () => { let fixture: ComponentFixture; let component: ProfileEditAccount; + let profileServiceSpy: { changePassword: ReturnType }; beforeEach(async () => { + profileServiceSpy = { + changePassword: vi.fn(), + }; + await TestBed.configureTestingModule({ imports: [ProfileEditAccount], + providers: [ + { provide: PatientProfileService, useValue: profileServiceSpy }, + { provide: ToastService, useValue: { error: vi.fn(), success: vi.fn() } }, + ], }).compileComponents(); fixture = TestBed.createComponent(ProfileEditAccount); component = fixture.componentInstance; fixture.componentRef.setInput('initialEmail', 'a@b.com'); - fixture.componentRef.setInput('hasPassword', false); + fixture.componentRef.setInput('hasPassword', true); fixture.detectChanges(); }); - it('should reject invalid email', () => { - component.form.patchValue({ loginEmail: 'invalid' }); + it('should not emit success when only current password is filled', () => { + const changed = vi.fn(); + component.passwordChanged.subscribe(changed); + + component.form.patchValue({ + currentPassword: 'errada', + newPassword: '', + confirmPassword: '', + }); component.submit(); - expect(component.showValidation()).toBe(true); + + expect(changed).not.toHaveBeenCalled(); + expect(component.localPasswordError()).toBe('incomplete'); }); - it('should show password error message', () => { - fixture.componentRef.setInput('passwordError', 'wrong_current'); - fixture.detectChanges(); + it('should show wrong current password error from API', () => { + profileServiceSpy.changePassword.mockReturnValue( + of({ ok: false, error: 'wrong_current' as const }) + ); + + component.form.patchValue({ + currentPassword: 'errada', + newPassword: 'novasenha1', + confirmPassword: 'novasenha1', + }); + component.submit(); + + expect(component.localPasswordError()).toBe('wrong_current'); expect(component.passwordErrorMessage()).toBe('Senha atual incorreta.'); }); }); diff --git a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts index 1053382..4ff6e69 100644 --- a/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts +++ b/frontend/src/app/features/profile/components/profile-edit-account/profile-edit-account.ts @@ -1,14 +1,11 @@ import { Component, computed, effect, inject, input, output, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { LucideAngularModule, LucideX } from 'lucide-angular'; -import type { ChangePasswordError } from '../../services/patient-profile.service'; - -export type AccountSavePayload = { - loginEmail: string; - currentPassword: string; - newPassword: string; - confirmPassword: string; -}; +import { ToastService } from '../../../../components/toast/toast.service'; +import { + PatientProfileService, + type ChangePasswordError, +} from '../../services/patient-profile.service'; @Component({ selector: 'app-profile-edit-account', @@ -18,21 +15,24 @@ export type AccountSavePayload = { }) export class ProfileEditAccount { private readonly fb = inject(FormBuilder); + private readonly profileService = inject(PatientProfileService); + private readonly toastService = inject(ToastService); readonly initialEmail = input.required(); readonly hasPassword = input(false); - readonly passwordError = input(null); - readonly saved = output(); + readonly passwordChanged = output(); readonly closed = output(); readonly LucideX = LucideX; readonly showValidation = signal(false); + readonly isSaving = signal(false); + readonly localPasswordError = signal(null); readonly form = this.fb.group({ loginEmail: ['', [Validators.required, Validators.email]], currentPassword: [''], - newPassword: ['', Validators.minLength(6)], + newPassword: ['', Validators.minLength(8)], confirmPassword: [''], }); @@ -40,6 +40,25 @@ export class ProfileEditAccount { this.hasPassword() ? 'Alterar senha' : 'Definir senha' ); + readonly passwordErrorMessage = computed(() => { + const err = this.localPasswordError(); + if (!err) return null; + switch (err) { + case 'wrong_current': + return 'Senha atual incorreta.'; + case 'current_required': + return 'Informe a senha atual.'; + case 'incomplete': + return 'Preencha a nova senha e a confirmação para alterar.'; + case 'mismatch': + return 'A nova senha e a confirmação não coincidem.'; + case 'too_short': + return 'A senha deve ter pelo menos 8 caracteres.'; + default: + return null; + } + }); + constructor() { effect(() => { this.form.patchValue({ loginEmail: this.initialEmail() }, { emitEvent: false }); @@ -57,36 +76,73 @@ export class ProfileEditAccount { } submit(): void { - const emailCtrl = this.form.controls.loginEmail; - const newPwd = this.form.controls.newPassword.value ?? ''; - const confirmPwd = this.form.controls.confirmPassword.value ?? ''; + this.localPasswordError.set(null); - if (emailCtrl.invalid) { + const validationError = this.validatePasswordForm(); + if (validationError) { + this.localPasswordError.set(validationError); this.showValidation.set(true); return; } + const current = this.form.controls.currentPassword.value ?? ''; + const newPwd = this.form.controls.newPassword.value ?? ''; + const confirmPwd = this.form.controls.confirmPassword.value ?? ''; + + this.isSaving.set(true); + this.profileService.changePassword(current, newPwd, confirmPwd).subscribe({ + next: (result) => { + this.isSaving.set(false); + if (!result.ok) { + this.localPasswordError.set(result.error); + if (result.error === 'wrong_current') { + this.toastService.error( + 'Senha atual incorreta', + 'Verifique a senha e tente novamente.' + ); + } + return; + } + this.passwordChanged.emit(); + }, + error: () => { + this.isSaving.set(false); + this.localPasswordError.set('wrong_current'); + this.toastService.error( + 'Erro ao alterar senha', + 'Não foi possível alterar a senha. Tente novamente.' + ); + }, + }); + } + + private validatePasswordForm(): ChangePasswordError | null { + const current = this.form.controls.currentPassword.value?.trim() ?? ''; + const newPwd = this.form.controls.newPassword.value ?? ''; + const confirmPwd = this.form.controls.confirmPassword.value ?? ''; + + const anyField = current.length > 0 || newPwd.length > 0 || confirmPwd.length > 0; + if (!anyField) { + return 'incomplete'; + } + const changingPassword = newPwd.length > 0 || confirmPwd.length > 0; - if (changingPassword && (this.form.controls.newPassword.invalid || newPwd !== confirmPwd)) { - this.showValidation.set(true); - return; + if (!changingPassword) { + return 'incomplete'; } - this.saved.emit(this.form.getRawValue() as AccountSavePayload); - } + if (this.hasPassword() && current.length === 0) { + return 'current_required'; + } - passwordErrorMessage(): string | null { - const err = this.passwordError(); - if (!err) return null; - switch (err) { - case 'wrong_current': - return 'Senha atual incorreta.'; - case 'mismatch': - return 'A nova senha e a confirmação não coincidem.'; - case 'too_short': - return 'A senha deve ter pelo menos 6 caracteres.'; - default: - return null; + if (newPwd.length < 8) { + return 'too_short'; } + + if (newPwd !== confirmPwd) { + return 'mismatch'; + } + + return null; } } diff --git a/frontend/src/app/features/profile/models/patient-booklet.models.ts b/frontend/src/app/features/profile/models/patient-booklet.models.ts new file mode 100644 index 0000000..6085bc9 --- /dev/null +++ b/frontend/src/app/features/profile/models/patient-booklet.models.ts @@ -0,0 +1,39 @@ +import type { PatientPersonalData, PatientTreatmentData } from './patient-profile.models'; + +export interface BookletSupervisedDoseRow { + doseNumber: number; + medicationName: string; + dateIso: string; + schedulingDateIso?: string; +} + +export interface BookletNeurologicalAssessmentRow { + contextLabel: string; + assessmentDate: string; + gifEye: string; + gifHand: string; + gifFoot: string; + highestGif: string; + ompSum: string; + conduct: string; + ubs: string; + reference: string; +} + +export interface BookletAppointmentRow { + dateIso: string; + time: string; + location: string; + typeLabel: string; + professional: string; + statusLabel: string; +} + +export interface PatientBookletData { + generatedAt: string; + personal: PatientPersonalData; + treatment: PatientTreatmentData; + supervisedDoses: BookletSupervisedDoseRow[]; + neurologicalAssessments: BookletNeurologicalAssessmentRow[]; + appointments: BookletAppointmentRow[]; +} diff --git a/frontend/src/app/features/profile/models/patient-personal-api.models.ts b/frontend/src/app/features/profile/models/patient-personal-api.models.ts new file mode 100644 index 0000000..d860261 --- /dev/null +++ b/frontend/src/app/features/profile/models/patient-personal-api.models.ts @@ -0,0 +1,35 @@ +export interface PatientPersonalRecordApi { + social_name: string; + cpf: string; + sus_card: string; + birth_date: string | null; + marital_status: string; + nationality: string; + race_color: string; + indigenous_ethnicity: string; + sex: string; + wants_gender_identity: string; + gender_identity: string; + gender_identity_other: string; + wants_sexual_orientation: string; + sexual_orientation: string; + sexual_orientation_other: string; + address: string; + phone: string; + email: string; + education: string; + occupation: string; + health_unit: string; + acs_name: string; + nurse_name: string; + doctor_name: string; + emergency_contact: string; + blood_type: string; + medication_allergies: string; +} + +export interface ChangePasswordApi { + current_password: string; + new_password: string; + confirm_password: string; +} diff --git a/frontend/src/app/features/profile/models/patient-treatment-api.models.ts b/frontend/src/app/features/profile/models/patient-treatment-api.models.ts new file mode 100644 index 0000000..24f2bd4 --- /dev/null +++ b/frontend/src/app/features/profile/models/patient-treatment-api.models.ts @@ -0,0 +1,94 @@ +export interface InstitutedMedicationApi { + name: string; + dose: string; + unit: string; + frequency: string; +} + +export interface PatientTreatmentRecordApi { + diagnosis_date: string | null; + classification: string | null; + current_dose_medication: string; + treatment_start_date: string | null; + cns_number: string; + sinan_number: string; + clinical_form: string; + baciloscopy_date: string | null; + baciloscopy_ib: string; + diagnostic_support_exam: string; + gif_assessment: string; + reaction_episode_at_diagnosis: string; + reaction_episode_type: string; + reaction_episode_date: string | null; + prednisone_mg_kg: string; + aine_mg_day: string; + thalidomide_mg_day: string; + pentoxifylline_mg_day: string; + other_medication: string; + instituted_medications: InstitutedMedicationApi[]; + other_conducts: string; + substitute_scheme_change_date: string | null; + intolerance_dapsone: boolean; + intolerance_rifampicin: boolean; + intolerance_clofazimine: boolean; + scheme_clofazimina: boolean; + scheme_ofloxacino: boolean; + scheme_rifampicina: boolean; + scheme_minociclina: boolean; + scheme_dapsone: boolean; + pqt_discharge_date: string | null; + gif_assessment_at_discharge: string; + reaction_episode_at_discharge: string; + reaction_episode_type_at_discharge: string; + reaction_episode_date_at_discharge: string | null; + discharge_prednisone_mg_kg: string; + discharge_aine_mg_day: string; + discharge_thalidomide_mg_day: string; + discharge_pentoxifylline_mg_day: string; + discharge_other_medication: string; + discharge_other_conducts: string; +} + +export interface MedicationChecklistApi { + active_treatment_id: string | null; + instituted_medications: InstitutedMedicationApi[]; + current_dose_medication: string; + treatment_start_date: string | null; + can_register_doses: boolean; +} + +export interface ActiveTreatmentApi { + id: string; + patient_id: string; + prescribed_by: string; + regimen: string; + start_date: string; + expected_end: string; + status: string; + notes: string | null; + created_at: string; + updated_at: string; +} + +export interface DoseLogCreateApi { + drug_name: string; + expected_at: string; + taken_at: string | null; + skipped: boolean; + skip_reason: string | null; + supervised: boolean; + via_consultation?: boolean; +} + +export interface DoseLogResponseApi { + id: string; + treatment_id: string; + drug_name: string; + expected_at: string; + taken_at: string | null; + skipped: boolean; + skip_reason: string | null; + supervised: boolean; + registered_by: string | null; + created_at: string; +} diff --git a/frontend/src/app/features/profile/profile.html b/frontend/src/app/features/profile/profile.html index b37f112..46e188d 100644 --- a/frontend/src/app/features/profile/profile.html +++ b/frontend/src/app/features/profile/profile.html @@ -28,34 +28,6 @@

Meu perfil

Nome de usuário atualizado.

} - @if (personalSavedToast()) { -

- Dados pessoais salvos. -

- } - @if (treatmentSavedToast()) { -

- Dados de tratamento salvos. -

- } - @if (exportPdfHint()) { -

- A exportação em PDF estará disponível em breve pelo servidor. -

- } -
Dados pessoais

@@ -1007,8 +981,7 @@

} diff --git a/frontend/src/app/features/profile/profile.ts b/frontend/src/app/features/profile/profile.ts index 4c7d436..c27f9c8 100644 --- a/frontend/src/app/features/profile/profile.ts +++ b/frontend/src/app/features/profile/profile.ts @@ -1,4 +1,5 @@ -import { Component, inject, signal, type WritableSignal } from '@angular/core'; +import { Component, inject, OnInit, signal, type WritableSignal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { LucideAngularModule, @@ -7,7 +8,6 @@ import { LucidePencil, LucideUser, } from 'lucide-angular'; -import type { AccountSavePayload } from './components/profile-edit-account/profile-edit-account'; import { ProfileEditAccount } from './components/profile-edit-account/profile-edit-account'; import { ProfileEditPersonal } from './components/profile-edit-personal/profile-edit-personal'; import { ProfileEditUsername } from './components/profile-edit-username/profile-edit-username'; @@ -39,6 +39,7 @@ import { PatientMedicationService } from '../appointments/services/patient-medic import { AuthService } from '../auth/services/auth-service'; import { getApiErrorMessage } from '../../core/api-error.utils'; import { ToastService } from '../../components/toast/toast.service'; +import { PatientBookletExportService } from './services/patient-booklet-export.service'; export type ProfileTab = 'overview' | 'treatment'; @@ -48,7 +49,7 @@ export type ProfileTab = 'overview' | 'treatment'; imports: [ReactiveFormsModule, LucideAngularModule, ProfileEditPersonal, ProfileEditAccount, ProfileEditUsername], templateUrl: './profile.html', }) -export class Profile { +export class Profile implements OnInit { readonly institutedMedicationNameOptions = INSTITUTED_MEDICATION_NAME_OPTIONS; readonly institutedMedicationUnitOptions = INSTITUTED_MEDICATION_UNIT_OPTIONS; readonly institutedMedicationFrequencyOptions = INSTITUTED_MEDICATION_FREQUENCY_OPTIONS; @@ -58,6 +59,11 @@ export class Profile { private readonly medicationService = inject(PatientMedicationService); private readonly authService = inject(AuthService); private readonly toastService = inject(ToastService); + private readonly route = inject(ActivatedRoute); + private readonly bookletExport = inject(PatientBookletExportService); + + readonly savingTreatment = signal(false); + readonly exportingBooklet = signal(false); readonly LucideDownload = LucideDownload; readonly LucideKeyRound = LucideKeyRound; @@ -87,12 +93,9 @@ export class Profile { readonly showAvatarMenu = signal(false); readonly accountPasswordError = signal(null); - readonly personalSavedToast = signal(false); readonly usernameSavedToast = signal(false); - readonly treatmentSavedToast = signal(false); readonly accountSavedToast = signal(false); readonly avatarRemovedToast = signal(false); - readonly exportPdfHint = signal(false); readonly hasReactionEpisodeAtDiagnosis = signal(''); readonly hasReactionEpisodeAtDischarge = signal(''); @@ -230,6 +233,28 @@ export class Profile { this.syncTreatmentForm(this.profileService.profile().treatment); } + ngOnInit(): void { + this.profileService.syncLoginEmailFromAuth(); + + this.profileService.syncPersonalFromApi().subscribe({ + error: () => undefined, + }); + + this.profileService.syncTreatmentFromApi().subscribe({ + next: (treatment) => { + if (treatment) { + this.syncTreatmentForm(treatment); + } + }, + error: () => undefined, + }); + + const tab = this.route.snapshot.queryParamMap.get('tab'); + if (tab === 'treatment') { + this.setTab('treatment'); + } + } + setTab(tab: ProfileTab): void { this.activeTab.set(tab); if (tab === 'treatment') { @@ -280,29 +305,21 @@ export class Profile { } onPersonalSaved(data: PatientPersonalData): void { - this.profileService.updatePersonal(data); - this.showEditPersonal.set(false); - this.showToast(this.personalSavedToast); + this.profileService.savePersonal(data).subscribe({ + next: () => { + this.showEditPersonal.set(false); + this.toastService.success('Dados pessoais salvos.'); + }, + error: (error) => { + this.toastService.error( + 'Erro ao salvar', + getApiErrorMessage(error, 'Não foi possível salvar os dados pessoais.') + ); + }, + }); } - onAccountSaved(payload: AccountSavePayload): void { - this.profileService.updateLoginEmail(payload.loginEmail); - - const changingPassword = - payload.newPassword.length > 0 || payload.confirmPassword.length > 0; - - if (changingPassword) { - const result = this.profileService.changePassword( - payload.currentPassword, - payload.newPassword, - payload.confirmPassword - ); - if (!result.ok) { - this.accountPasswordError.set(result.error); - return; - } - } - + onPasswordChanged(): void { this.accountPasswordError.set(null); this.showEditAccount.set(false); this.showToast(this.accountSavedToast); @@ -425,18 +442,56 @@ export class Profile { dischargeOtherConducts: raw.reactionEpisodeAtDischarge === 'sim' ? (raw.dischargeOtherConducts ?? '') : '', }; - this.profileService.updateTreatment(treatment); this.medicationService.setCurrentDoseMedication(treatment.currentDoseMedication); - this.showToast(this.treatmentSavedToast); + + if (!this.authService.isAuthenticated()) { + this.profileService.updateTreatment(treatment); + this.toastService.success('Dados de tratamento salvos.'); + return; + } + + this.savingTreatment.set(true); + this.profileService.saveTreatment(treatment).subscribe({ + next: (saved) => { + this.savingTreatment.set(false); + this.syncTreatmentForm(saved); + this.toastService.success('Dados de tratamento salvos.'); + }, + error: () => { + this.savingTreatment.set(false); + this.profileService.updateTreatment(treatment); + this.toastService.error( + 'Erro ao salvar tratamento', + 'Dados guardados localmente. Tente novamente quando estiver online.', + ); + }, + }); } - onExportPdf(): void { - this.exportPdfHint.set(true); - setTimeout(() => this.exportPdfHint.set(false), 4000); + onExportBooklet(): void { + if (this.exportingBooklet()) return; + + this.exportingBooklet.set(true); + this.bookletExport.exportAndDownload().subscribe({ + next: () => { + this.exportingBooklet.set(false); + this.toastService.success( + 'Cartilha exportada', + 'Arquivo baixado. Leia o aviso no documento: cópia do Pequi, não é documento médico oficial.' + ); + }, + error: () => { + this.exportingBooklet.set(false); + this.toastService.error( + 'Exportação', + 'Não foi possível gerar a cartilha. Verifique sua conexão e tente novamente.' + ); + }, + }); } hasAccountPassword(): boolean { - return this.profile().account.password.length > 0; + return this.authService.isAuthenticated() || this.profile().account.password.length > 0; } maskedLoginEmail(): string { diff --git a/frontend/src/app/features/profile/services/patient-booklet-export.service.ts b/frontend/src/app/features/profile/services/patient-booklet-export.service.ts new file mode 100644 index 0000000..1b68e64 --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-booklet-export.service.ts @@ -0,0 +1,94 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, catchError, forkJoin, map, of, switchMap, tap } from 'rxjs'; + +import { environment } from '../../../../environments/environment'; +import { AuthService } from '../../auth/services/auth-service'; +import { HealthAppointmentService } from '../../appointments/services/health-appointment.service'; +import type { AccountExportDoseLog } from '../utils/booklet-export-data.utils'; +import { buildBookletData } from '../utils/booklet-export-data.utils'; +import { buildBookletHtml } from '../utils/booklet-html.builder'; +import { PatientProfileService } from './patient-profile.service'; + +interface AccountExportResponse { + dose_logs?: AccountExportDoseLog[]; +} + +@Injectable({ providedIn: 'root' }) +export class PatientBookletExportService { + private readonly http = inject(HttpClient); + private readonly authService = inject(AuthService); + private readonly profileService = inject(PatientProfileService); + private readonly appointmentService = inject(HealthAppointmentService); + + exportAndDownload(): Observable { + return this.loadSources().pipe( + tap(({ doseLogs }) => { + const data = buildBookletData( + this.profileService.profile(), + this.appointmentService.appointments(), + doseLogs + ); + this.triggerDownload(buildBookletHtml(data)); + }), + map(() => undefined) + ); + } + + private loadSources(): Observable<{ doseLogs: AccountExportDoseLog[] }> { + const appointments$ = this.authService.isAuthenticated() + ? this.appointmentService.syncFromApi().pipe( + map(() => undefined), + catchError(() => of(undefined)) + ) + : of(undefined); + + const profile$ = this.authService.isAuthenticated() + ? forkJoin([ + this.profileService.syncPersonalFromApi().pipe( + map(() => undefined), + catchError(() => of(undefined)) + ), + this.profileService.syncTreatmentFromApi().pipe( + map(() => undefined), + catchError(() => of(undefined)) + ), + ]) + : of(undefined); + + const doseLogs$ = this.authService.isAuthenticated() + ? this.http.get(`${environment.apiUrl}/v1/account/export`).pipe( + map((body) => body.dose_logs ?? []), + catchError(() => of([] as AccountExportDoseLog[])) + ) + : of([] as AccountExportDoseLog[]); + + return forkJoin([appointments$, profile$, doseLogs$]).pipe( + switchMap(([, , doseLogs]) => of({ doseLogs })) + ); + } + + private triggerDownload(html: string): void { + const datePart = new Date().toISOString().slice(0, 10); + const filename = `cartilha-hanseniase-${datePart}.html`; + + const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + link.click(); + URL.revokeObjectURL(blobUrl); + + const printWindow = window.open('', '_blank'); + if (printWindow) { + printWindow.document.open(); + printWindow.document.write(html); + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { + printWindow.print(); + }, 400); + } + } +} diff --git a/frontend/src/app/features/profile/services/patient-personal-api.mapper.ts b/frontend/src/app/features/profile/services/patient-personal-api.mapper.ts new file mode 100644 index 0000000..a4f18eb --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-personal-api.mapper.ts @@ -0,0 +1,82 @@ +import type { PatientPersonalData } from '../models/patient-profile.models'; +import type { PatientPersonalRecordApi } from '../models/patient-personal-api.models'; + +function toApiDate(value: string): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function fromApiDate(value: string | null | undefined): string { + return value ?? ''; +} + +export function personalDataToApiRecord( + data: PatientPersonalData, + fullName: string +): PatientPersonalRecordApi { + return { + social_name: data.socialName, + cpf: data.cpf, + sus_card: data.susCard, + birth_date: toApiDate(data.birthDate), + marital_status: data.maritalStatus, + nationality: data.nationality, + race_color: data.raceColor, + indigenous_ethnicity: data.indigenousEthnicity, + sex: data.sex, + wants_gender_identity: data.wantsGenderIdentity, + gender_identity: data.genderIdentity, + gender_identity_other: data.genderIdentityOther, + wants_sexual_orientation: data.wantsSexualOrientation, + sexual_orientation: data.sexualOrientation, + sexual_orientation_other: data.sexualOrientationOther, + address: data.address, + phone: data.phone, + email: data.email, + education: data.education, + occupation: data.occupation, + health_unit: data.healthUnit, + acs_name: data.acsName, + nurse_name: data.nurseName, + doctor_name: data.doctorName, + emergency_contact: data.emergencyContact, + blood_type: data.bloodType, + medication_allergies: data.medicationAllergies, + }; +} + +export function apiRecordToPersonalData( + api: PatientPersonalRecordApi, + fullName: string +): PatientPersonalData { + return { + fullName, + socialName: api.social_name ?? '', + cpf: api.cpf ?? '', + susCard: api.sus_card ?? '', + birthDate: fromApiDate(api.birth_date), + maritalStatus: api.marital_status ?? '', + nationality: api.nationality ?? '', + raceColor: api.race_color ?? '', + indigenousEthnicity: api.indigenous_ethnicity ?? '', + sex: api.sex ?? '', + wantsGenderIdentity: (api.wants_gender_identity ?? '') as PatientPersonalData['wantsGenderIdentity'], + genderIdentity: api.gender_identity ?? '', + genderIdentityOther: api.gender_identity_other ?? '', + wantsSexualOrientation: (api.wants_sexual_orientation ?? '') as PatientPersonalData['wantsSexualOrientation'], + sexualOrientation: api.sexual_orientation ?? '', + sexualOrientationOther: api.sexual_orientation_other ?? '', + address: api.address ?? '', + phone: api.phone ?? '', + email: api.email ?? '', + education: api.education ?? '', + occupation: api.occupation ?? '', + healthUnit: api.health_unit ?? '', + acsName: api.acs_name ?? '', + nurseName: api.nurse_name ?? '', + doctorName: api.doctor_name ?? '', + emergencyContact: api.emergency_contact ?? '', + bloodType: api.blood_type ?? '', + medicationAllergies: api.medication_allergies ?? '', + }; +} diff --git a/frontend/src/app/features/profile/services/patient-personal.service.ts b/frontend/src/app/features/profile/services/patient-personal.service.ts new file mode 100644 index 0000000..c2a9ec5 --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-personal.service.ts @@ -0,0 +1,50 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, map } from 'rxjs'; + +import { environment } from '../../../../environments/environment'; +import type { PatientPersonalData } from '../models/patient-profile.models'; +import type { + ChangePasswordApi, + PatientPersonalRecordApi, +} from '../models/patient-personal-api.models'; +import { + apiRecordToPersonalData, + personalDataToApiRecord, +} from './patient-personal-api.mapper'; + +@Injectable({ providedIn: 'root' }) +export class PatientPersonalService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/v1/patients/me`; + private readonly accountUrl = `${environment.apiUrl}/v1/account`; + + loadPersonalRecord(fullName: string): Observable { + return this.http.get(`${this.baseUrl}/personal-record`).pipe( + map((record) => apiRecordToPersonalData(record, fullName)) + ); + } + + savePersonalRecord( + data: PatientPersonalData, + fullName: string + ): Observable { + const body = personalDataToApiRecord(data, fullName); + return this.http + .put(`${this.baseUrl}/personal-record`, body) + .pipe(map((record) => apiRecordToPersonalData(record, fullName))); + } + + changePassword( + currentPassword: string, + newPassword: string, + confirmPassword: string + ): Observable { + const body: ChangePasswordApi = { + current_password: currentPassword, + new_password: newPassword, + confirm_password: confirmPassword, + }; + return this.http.post(`${this.accountUrl}/password`, body); + } +} diff --git a/frontend/src/app/features/profile/services/patient-profile.service.spec.ts b/frontend/src/app/features/profile/services/patient-profile.service.spec.ts index c2feb9f..e1c1fc2 100644 --- a/frontend/src/app/features/profile/services/patient-profile.service.spec.ts +++ b/frontend/src/app/features/profile/services/patient-profile.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; import { EMPTY_PERSONAL_DATA, EMPTY_TREATMENT_DATA } from '../models/patient-profile.models'; import { AuthService } from '../../auth/services/auth-service'; import { PatientProfileService } from './patient-profile.service'; @@ -9,7 +10,8 @@ describe('PatientProfileService', () => { const authServiceMock = { displayName: signal('Paciente'), - currentUser: signal<{ full_name: string } | null>(null), + currentUser: signal<{ full_name: string; email?: string } | null>(null), + isAuthenticated: () => false, }; beforeEach(() => { @@ -38,21 +40,25 @@ describe('PatientProfileService', () => { expect(service.legalFullName()).toBe('Maria Silva'); }); - it('should lock full name when saving personal data', () => { + it('should lock full name when saving personal data', async () => { authServiceMock.currentUser.set({ full_name: 'João Souza' }); - service.updatePersonal({ - ...EMPTY_PERSONAL_DATA, - fullName: 'Outro Nome', - }); + await firstValueFrom( + service.savePersonal({ + ...EMPTY_PERSONAL_DATA, + fullName: 'Outro Nome', + }) + ); expect(service.profile().personal.fullName).toBe('João Souza'); }); - it('should persist personal data to localStorage', () => { - service.updatePersonal({ - ...EMPTY_PERSONAL_DATA, - fullName: 'João Souza', - cpf: '123.456.789-00', - }); + it('should persist personal data to localStorage', async () => { + await firstValueFrom( + service.savePersonal({ + ...EMPTY_PERSONAL_DATA, + fullName: 'João Souza', + cpf: '123.456.789-00', + }) + ); const raw = localStorage.getItem('pequi.patient_profile'); expect(raw).toBeTruthy(); @@ -79,21 +85,29 @@ describe('PatientProfileService', () => { expect(service.profile().avatarDataUrl).toBe(''); }); - it('should update login email', () => { - service.updateLoginEmail('paciente@email.com'); + it('should sync login email from auth user', () => { + authServiceMock.currentUser.set({ + full_name: 'Maria', + email: 'paciente@email.com', + }); + service.syncLoginEmailFromAuth(); expect(service.profile().account.loginEmail).toBe('paciente@email.com'); }); - it('should change password when none set', () => { - const result = service.changePassword('', 'senha123', 'senha123'); - expect(result.ok).toBe(true); - expect(service.profile().account.password).toBe('senha123'); - }); - - it('should reject wrong current password', () => { - service.changePassword('', 'senha123', 'senha123'); - const result = service.changePassword('errada', 'nova123', 'nova123'); + it('should reject password change without local stored password', async () => { + const result = await firstValueFrom( + service.changePassword('qualquer', 'senha12345', 'senha12345') + ); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe('wrong_current'); }); + + it('should change password when local stored password matches', async () => { + await firstValueFrom(service.changePassword('', 'senha12345', 'senha12345')); + const result = await firstValueFrom( + service.changePassword('senha12345', 'outrasenha1', 'outrasenha1') + ); + expect(result.ok).toBe(true); + expect(service.profile().account.password).toBe('outrasenha1'); + }); }); diff --git a/frontend/src/app/features/profile/services/patient-profile.service.ts b/frontend/src/app/features/profile/services/patient-profile.service.ts index 15466b5..3f95afa 100644 --- a/frontend/src/app/features/profile/services/patient-profile.service.ts +++ b/frontend/src/app/features/profile/services/patient-profile.service.ts @@ -1,5 +1,9 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { computed, inject, Injectable, signal } from '@angular/core'; +import { Observable, catchError, map, of, tap } from 'rxjs'; import { AuthService } from '../../auth/services/auth-service'; +import { PatientPersonalService } from './patient-personal.service'; +import { PatientTreatmentService } from './patient-treatment.service'; import { EMPTY_PATIENT_PROFILE, type PatientAccountData, @@ -10,7 +14,40 @@ import { const STORAGE_KEY = 'pequi.patient_profile'; -export type ChangePasswordError = 'wrong_current' | 'mismatch' | 'too_short'; +export type ChangePasswordError = + | 'wrong_current' + | 'mismatch' + | 'too_short' + | 'incomplete' + | 'current_required'; + +export function extractHttpErrorDetail(error: HttpErrorResponse): string { + if (typeof error.error === 'string') { + return error.error; + } + if ( + error.error && + typeof error.error === 'object' && + 'detail' in error.error && + typeof (error.error as { detail?: unknown }).detail === 'string' + ) { + return (error.error as { detail: string }).detail; + } + return ''; +} + +export function mapChangePasswordHttpError(error: unknown): ChangePasswordError { + const httpError = error as HttpErrorResponse; + const detail = extractHttpErrorDetail(httpError).toLowerCase(); + + if (httpError.status === 401 || detail.includes('atual')) { + return 'wrong_current'; + } + if (detail.includes('coincidem')) { + return 'mismatch'; + } + return 'too_short'; +} export type ChangePasswordResult = | { ok: true } @@ -19,7 +56,11 @@ export type ChangePasswordResult = @Injectable({ providedIn: 'root' }) export class PatientProfileService { private readonly authService = inject(AuthService); + private readonly personalApi = inject(PatientPersonalService); + private readonly treatmentApi = inject(PatientTreatmentService); private readonly profileSignal = signal(this.loadFromStorage()); + private readonly treatmentSyncedSignal = signal(false); + private readonly personalSyncedSignal = signal(false); readonly profile = this.profileSignal.asReadonly(); readonly displayName = this.authService.displayName; @@ -62,15 +103,44 @@ export class PatientProfileService { }); } - updateLoginEmail(loginEmail: string): void { + syncLoginEmailFromAuth(): void { + const email = this.authService.currentUser()?.email?.trim() ?? ''; + if (!email) return; const account = this.profileSignal().account; - this.updateAccount({ ...account, loginEmail: loginEmail.trim() }); + if (account.loginEmail === email) return; + this.updateAccount({ ...account, loginEmail: email }); } changePassword( currentPassword: string, newPassword: string, confirmPassword: string + ): Observable { + if (newPassword.length < 8) { + return of({ ok: false, error: 'too_short' }); + } + if (newPassword !== confirmPassword) { + return of({ ok: false, error: 'mismatch' }); + } + + if (!this.authService.isAuthenticated()) { + return of(this.changePasswordLocal(currentPassword, newPassword, confirmPassword)); + } + + if (!currentPassword.trim()) { + return of({ ok: false as const, error: 'current_required' as const }); + } + + return this.personalApi.changePassword(currentPassword, newPassword, confirmPassword).pipe( + map(() => ({ ok: true as const })), + catchError((error: unknown) => of({ ok: false as const, error: mapChangePasswordHttpError(error) })) + ); + } + + private changePasswordLocal( + currentPassword: string, + newPassword: string, + confirmPassword: string ): ChangePasswordResult { if (newPassword.length < 6) { return { ok: false, error: 'too_short' }; @@ -78,31 +148,89 @@ export class PatientProfileService { if (newPassword !== confirmPassword) { return { ok: false, error: 'mismatch' }; } - const stored = this.profileSignal().account.password; - if (stored && stored !== currentPassword) { + if (!stored || stored !== currentPassword) { return { ok: false, error: 'wrong_current' }; } - const account = this.profileSignal().account; this.updateAccount({ ...account, password: newPassword }); return { ok: true }; } - updatePersonal(personal: PatientPersonalData): void { + savePersonal(personal: PatientPersonalData): Observable { const lockedFullName = this.legalFullName(); - this.patch({ - personal: { - ...personal, - fullName: lockedFullName || personal.fullName, - }, - }); + const normalized: PatientPersonalData = { + ...personal, + fullName: lockedFullName || personal.fullName, + }; + this.patch({ personal: normalized }); + + if (!this.authService.isAuthenticated()) { + return of(normalized); + } + + return this.personalApi.savePersonalRecord(normalized, lockedFullName).pipe( + tap((saved) => { + this.patch({ personal: saved }); + this.personalSyncedSignal.set(true); + this.persist(this.profileSignal()); + }) + ); + } + + syncPersonalFromApi(): Observable { + if (!this.authService.isAuthenticated()) { + return of(null); + } + + const fullName = this.legalFullName(); + return this.personalApi.loadPersonalRecord(fullName).pipe( + tap((personal) => { + this.syncLoginEmailFromAuth(); + this.patch({ personal }); + this.personalSyncedSignal.set(true); + this.persist(this.profileSignal()); + }) + ); } + readonly treatmentSynced = this.treatmentSyncedSignal.asReadonly(); + readonly personalSynced = this.personalSyncedSignal.asReadonly(); + updateTreatment(treatment: PatientTreatmentData): void { this.patch({ treatment: { ...treatment } }); } + saveTreatment(treatment: PatientTreatmentData): Observable { + this.patch({ treatment: { ...treatment } }); + + if (!this.authService.isAuthenticated()) { + return of(treatment); + } + + return this.treatmentApi.saveTreatmentRecord(treatment).pipe( + tap((saved) => { + this.patch({ treatment: saved }); + this.treatmentSyncedSignal.set(true); + this.persist(this.profileSignal()); + }), + ); + } + + syncTreatmentFromApi(): Observable { + if (!this.authService.isAuthenticated()) { + return of(null); + } + + return this.treatmentApi.loadTreatmentRecord().pipe( + tap((treatment) => { + this.patch({ treatment }); + this.treatmentSyncedSignal.set(true); + this.persist(this.profileSignal()); + }), + ); + } + reset(): void { this.profileSignal.set(structuredClone(EMPTY_PATIENT_PROFILE)); this.persist(this.profileSignal()); diff --git a/frontend/src/app/features/profile/services/patient-treatment-api.mapper.ts b/frontend/src/app/features/profile/services/patient-treatment-api.mapper.ts new file mode 100644 index 0000000..54d634a --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-treatment-api.mapper.ts @@ -0,0 +1,126 @@ +import type { PatientTreatmentData } from '../models/patient-profile.models'; +import type { PatientTreatmentRecordApi } from '../models/patient-treatment-api.models'; + +function toApiDate(value: string): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function fromApiDate(value: string | null | undefined): string { + return value ?? ''; +} + +export function treatmentDataToApiRecord( + data: PatientTreatmentData +): PatientTreatmentRecordApi { + return { + diagnosis_date: toApiDate(data.diagnosisDate), + classification: data.classification || null, + current_dose_medication: data.currentDoseMedication, + treatment_start_date: toApiDate(data.treatmentStartDate), + cns_number: data.cnsNumber, + sinan_number: data.sinanNumber, + clinical_form: data.clinicalForm, + baciloscopy_date: toApiDate(data.baciloscopyDate), + baciloscopy_ib: data.baciloscopyIB, + diagnostic_support_exam: data.diagnosticSupportExam, + gif_assessment: data.gifAssessment, + reaction_episode_at_diagnosis: data.reactionEpisodeAtDiagnosis, + reaction_episode_type: data.reactionEpisodeType, + reaction_episode_date: toApiDate(data.reactionEpisodeDate), + prednisone_mg_kg: data.prednisoneMgKg, + aine_mg_day: data.aineMgDay, + thalidomide_mg_day: data.thalidomideMgDay, + pentoxifylline_mg_day: data.pentoxifyllineMgDay, + other_medication: data.otherMedication, + instituted_medications: data.institutedMedications.map((item) => ({ + name: item.name, + dose: item.dose, + unit: item.unit, + frequency: item.frequency, + })), + other_conducts: data.otherConducts, + substitute_scheme_change_date: toApiDate(data.substituteSchemeChangeDate), + intolerance_dapsone: data.intoleranceDapsone, + intolerance_rifampicin: data.intoleranceRifampicin, + intolerance_clofazimine: data.intoleranceClofazimine, + scheme_clofazimina: data.schemeClofazimina, + scheme_ofloxacino: data.schemeOfloxacino, + scheme_rifampicina: data.schemeRifampicina, + scheme_minociclina: data.schemeMinociclina, + scheme_dapsone: data.schemeDapsone, + pqt_discharge_date: toApiDate(data.pqtDischargeDate), + gif_assessment_at_discharge: data.gifAssessmentAtDischarge, + reaction_episode_at_discharge: data.reactionEpisodeAtDischarge, + reaction_episode_type_at_discharge: data.reactionEpisodeTypeAtDischarge, + reaction_episode_date_at_discharge: toApiDate(data.reactionEpisodeDateAtDischarge), + discharge_prednisone_mg_kg: data.dischargePrednisoneMgKg, + discharge_aine_mg_day: data.dischargeAineMgDay, + discharge_thalidomide_mg_day: data.dischargeThalidomideMgDay, + discharge_pentoxifylline_mg_day: data.dischargePentoxifyllineMgDay, + discharge_other_medication: data.dischargeOtherMedication, + discharge_other_conducts: data.dischargeOtherConducts, + }; +} + +export function apiRecordToTreatmentData( + api: PatientTreatmentRecordApi +): PatientTreatmentData { + return { + currentDoseMedication: api.current_dose_medication ?? '', + diagnosisDate: fromApiDate(api.diagnosis_date), + cnsNumber: api.cns_number ?? '', + sinanNumber: api.sinan_number ?? '', + classification: (api.classification as PatientTreatmentData['classification']) ?? '', + treatmentStartDate: fromApiDate(api.treatment_start_date), + clinicalForm: (api.clinical_form as PatientTreatmentData['clinicalForm']) ?? '', + baciloscopyDate: fromApiDate(api.baciloscopy_date), + baciloscopyIB: api.baciloscopy_ib ?? '', + diagnosticSupportExam: api.diagnostic_support_exam ?? '', + gifAssessment: (api.gif_assessment as PatientTreatmentData['gifAssessment']) ?? '', + reactionEpisodeAtDiagnosis: + (api.reaction_episode_at_diagnosis as PatientTreatmentData['reactionEpisodeAtDiagnosis']) ?? + '', + reactionEpisodeType: + (api.reaction_episode_type as PatientTreatmentData['reactionEpisodeType']) ?? '', + reactionEpisodeDate: fromApiDate(api.reaction_episode_date), + prednisoneMgKg: api.prednisone_mg_kg ?? '', + aineMgDay: api.aine_mg_day ?? '', + thalidomideMgDay: api.thalidomide_mg_day ?? '', + pentoxifyllineMgDay: api.pentoxifylline_mg_day ?? '', + otherMedication: api.other_medication ?? '', + institutedMedications: (api.instituted_medications ?? []).map((item) => ({ + name: item.name, + dose: item.dose ?? '', + unit: item.unit ?? 'mg', + frequency: item.frequency ?? 'dia', + })), + otherConducts: api.other_conducts ?? '', + substituteSchemeChangeDate: fromApiDate(api.substitute_scheme_change_date), + intoleranceDapsone: api.intolerance_dapsone ?? false, + intoleranceRifampicin: api.intolerance_rifampicin ?? false, + intoleranceClofazimine: api.intolerance_clofazimine ?? false, + schemeClofazimina: api.scheme_clofazimina ?? false, + schemeOfloxacino: api.scheme_ofloxacino ?? false, + schemeRifampicina: api.scheme_rifampicina ?? false, + schemeMinociclina: api.scheme_minociclina ?? false, + schemeDapsone: api.scheme_dapsone ?? false, + pqtDischargeDate: fromApiDate(api.pqt_discharge_date), + gifAssessmentAtDischarge: + (api.gif_assessment_at_discharge as PatientTreatmentData['gifAssessmentAtDischarge']) ?? + '', + reactionEpisodeAtDischarge: + (api.reaction_episode_at_discharge as PatientTreatmentData['reactionEpisodeAtDischarge']) ?? + '', + reactionEpisodeTypeAtDischarge: + (api.reaction_episode_type_at_discharge as PatientTreatmentData['reactionEpisodeTypeAtDischarge']) ?? + '', + reactionEpisodeDateAtDischarge: fromApiDate(api.reaction_episode_date_at_discharge), + dischargePrednisoneMgKg: api.discharge_prednisone_mg_kg ?? '', + dischargeAineMgDay: api.discharge_aine_mg_day ?? '', + dischargeThalidomideMgDay: api.discharge_thalidomide_mg_day ?? '', + dischargePentoxifyllineMgDay: api.discharge_pentoxifylline_mg_day ?? '', + dischargeOtherMedication: api.discharge_other_medication ?? '', + dischargeOtherConducts: api.discharge_other_conducts ?? '', + }; +} diff --git a/frontend/src/app/features/profile/services/patient-treatment.service.ts b/frontend/src/app/features/profile/services/patient-treatment.service.ts new file mode 100644 index 0000000..ee79049 --- /dev/null +++ b/frontend/src/app/features/profile/services/patient-treatment.service.ts @@ -0,0 +1,135 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, forkJoin, map, of, switchMap, tap } from 'rxjs'; + +import { environment } from '../../../../environments/environment'; +import type { + ActiveTreatmentApi, + DoseLogCreateApi, + DoseLogResponseApi, + MedicationChecklistApi, + PatientTreatmentRecordApi, +} from '../models/patient-treatment-api.models'; +import type { PatientTreatmentData } from '../models/patient-profile.models'; +import { + apiRecordToTreatmentData, + treatmentDataToApiRecord, +} from './patient-treatment-api.mapper'; + +@Injectable({ providedIn: 'root' }) +export class PatientTreatmentService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/v1/patients/me`; + + private readonly activeTreatmentIdSignal = signal(null); + private readonly canRegisterDosesSignal = signal(false); + + readonly activeTreatmentId = this.activeTreatmentIdSignal.asReadonly(); + readonly canRegisterDoses = this.canRegisterDosesSignal.asReadonly(); + + loadTreatmentRecord(): Observable { + return this.http.get(`${this.baseUrl}/treatment-record`).pipe( + map((record) => apiRecordToTreatmentData(record)), + tap(() => this.refreshActiveTreatment()), + ); + } + + saveTreatmentRecord(data: PatientTreatmentData): Observable { + const body = treatmentDataToApiRecord(data); + return this.http.put(`${this.baseUrl}/treatment-record`, body).pipe( + switchMap((record) => + this.getMedicationChecklist().pipe(map(() => apiRecordToTreatmentData(record))) + ), + ); + } + + getMedicationChecklist(): Observable { + return this.http + .get(`${this.baseUrl}/medication-checklist`) + .pipe(tap((response) => this.applyChecklistMeta(response))); + } + + getActiveTreatment(): Observable { + return this.http + .get(`${this.baseUrl}/active-treatment`) + .pipe( + tap((treatment) => { + this.activeTreatmentIdSignal.set(treatment?.id ?? null); + this.canRegisterDosesSignal.set(treatment?.status === 'active'); + }), + ); + } + + registerDose( + treatmentId: string, + payload: DoseLogCreateApi + ): Observable { + return this.http.post( + `${environment.apiUrl}/v1/treatments/${treatmentId}/doses`, + payload + ); + } + + /** Dose diária em casa — tela Remédios. */ + registerTakenDose(drugName: string): Observable { + return this.registerDoseForTreatment(drugName, { supervised: false, via_consultation: false }); + } + + /** Dose supervisionada — somente ao registrar consulta realizada. */ + registerSupervisedDosesFromConsultation( + drugNames: string[] + ): Observable { + const names = drugNames.map((name) => name.trim()).filter(Boolean); + if (names.length === 0) { + return of([]); + } + + const requests = names.map((drugName) => + this.registerDoseForTreatment(drugName, { + supervised: true, + via_consultation: true, + }) + ); + + return forkJoin(requests).pipe( + map((results) => results.filter((result): result is DoseLogResponseApi => result !== null)) + ); + } + + private registerDoseForTreatment( + drugName: string, + options: { supervised: boolean; via_consultation: boolean } + ): Observable { + const treatmentId = this.activeTreatmentIdSignal(); + if (!treatmentId || !this.canRegisterDosesSignal()) { + return of(null); + } + + const now = new Date(); + const expectedAt = startOfLocalDayIso(now); + + return this.registerDose(treatmentId, { + drug_name: drugName, + expected_at: expectedAt, + taken_at: now.toISOString(), + skipped: false, + skip_reason: null, + supervised: options.supervised, + via_consultation: options.via_consultation, + }); + } + + refreshActiveTreatment(): void { + this.getActiveTreatment().subscribe({ error: () => undefined }); + } + + private applyChecklistMeta(response: MedicationChecklistApi): void { + this.activeTreatmentIdSignal.set(response.active_treatment_id); + this.canRegisterDosesSignal.set(response.can_register_doses); + } +} + +function startOfLocalDayIso(date: Date): string { + const local = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 8, 0, 0); + return local.toISOString(); +} diff --git a/frontend/src/app/features/profile/utils/booklet-export-data.utils.spec.ts b/frontend/src/app/features/profile/utils/booklet-export-data.utils.spec.ts new file mode 100644 index 0000000..7e02379 --- /dev/null +++ b/frontend/src/app/features/profile/utils/booklet-export-data.utils.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import type { HealthAppointment } from '../../appointments/models/health-appointment.models'; +import { buildNeurologicalRows, buildSupervisedDoseRows } from './booklet-export-data.utils'; + +describe('booklet export data', () => { + it('collects supervised doses from appointments', () => { + const rows = buildSupervisedDoseRows([ + { + id: '1', + appointmentDate: '2026-03-15', + appointmentTime: '10:00', + location: 'UBS', + type: 'dose_supervisionada', + performed: true, + status: 'completed', + wantsFollowUpDetails: true, + followUp: { + supervisedDose: { medicationName: 'Rifampicina + Dapsona' }, + }, + createdAt: '2026-03-15T10:00:00Z', + } as HealthAppointment, + ]); + + expect(rows).toHaveLength(2); + expect(rows[0]?.medicationName).toBe('Rifampicina'); + expect(rows[1]?.medicationName).toBe('Dapsona'); + }); + + it('collects neurological assessments from consultations', () => { + const rows = buildNeurologicalRows([ + { + id: '1', + appointmentDate: '2026-04-01', + appointmentTime: '09:00', + location: 'UBS', + type: 'avaliacao_neurologica', + performed: true, + status: 'completed', + wantsFollowUpDetails: true, + followUp: { + neurologicalAssessment: { + assessmentDate: '2026-04-01', + gifEye: '1', + gifHand: '0', + gifFoot: '1', + highestGif: '1', + ompSum: '2', + }, + }, + createdAt: '2026-04-01T09:00:00Z', + } as HealthAppointment, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]?.gifEye).toBe('1'); + expect(rows[0]?.ompSum).toBe('2'); + }); +}); diff --git a/frontend/src/app/features/profile/utils/booklet-export-data.utils.ts b/frontend/src/app/features/profile/utils/booklet-export-data.utils.ts new file mode 100644 index 0000000..3795aa1 --- /dev/null +++ b/frontend/src/app/features/profile/utils/booklet-export-data.utils.ts @@ -0,0 +1,211 @@ +import { APPOINTMENT_TYPES, type HealthAppointment } from '../../appointments/models/health-appointment.models'; +import { formatAppointmentDatePt } from '../../appointments/utils/next-appointment.utils'; +import type { + BookletAppointmentRow, + BookletNeurologicalAssessmentRow, + BookletSupervisedDoseRow, + PatientBookletData, +} from '../models/patient-booklet.models'; +import type { PatientPersonalData, PatientProfile, PatientTreatmentData } from '../models/patient-profile.models'; +import { + CLASSIFICATION_OPTIONS, + CLINICAL_FORM_OPTIONS, + EDUCATION_OPTIONS, + GENDER_IDENTITY_OPTIONS, + GIF_GRADE_OPTIONS, + MARITAL_STATUS_OPTIONS, + NATIONALITY_OPTIONS, + RACE_COLOR_OPTIONS, + REACTION_EPISODE_TYPE_OPTIONS, + SEX_OPTIONS, + SEXUAL_ORIENTATION_OPTIONS, + YES_NO_OPTIONS, + type GifGrade, + type YesNoChoice, +} from '../models/patient-profile.models'; + +export interface AccountExportDoseLog { + drug_name: string; + expected_at: string; + taken_at: string | null; + supervised: boolean; + skipped: boolean; +} + +export function buildBookletData( + profile: PatientProfile, + appointments: readonly HealthAppointment[], + doseLogs: AccountExportDoseLog[] = [] +): PatientBookletData { + return { + generatedAt: new Date().toISOString(), + personal: profile.personal, + treatment: profile.treatment, + supervisedDoses: buildSupervisedDoseRows(appointments, doseLogs), + neurologicalAssessments: buildNeurologicalRows(appointments), + appointments: buildAppointmentRows(appointments), + }; +} + +export function buildSupervisedDoseRows( + appointments: readonly HealthAppointment[], + doseLogs: AccountExportDoseLog[] = [] +): BookletSupervisedDoseRow[] { + const rows: BookletSupervisedDoseRow[] = []; + const seen = new Set(); + + for (const log of doseLogs) { + if (!log.supervised || log.skipped) continue; + const dateIso = isoDateFromTimestamp(log.taken_at ?? log.expected_at); + if (!dateIso) continue; + const key = `${dateIso}|${log.drug_name}`; + if (seen.has(key)) continue; + seen.add(key); + rows.push({ + doseNumber: rows.length + 1, + medicationName: log.drug_name, + dateIso, + }); + } + + const sortedAppointments = [...appointments].sort((a, b) => + b.appointmentDate.localeCompare(a.appointmentDate) + ); + + for (const apt of sortedAppointments) { + if (!apt.performed) continue; + const supervised = apt.followUp?.supervisedDose; + if (!supervised?.medicationName) continue; + + const meds = supervised.medicationName + .split('+') + .map((part) => part.trim()) + .filter(Boolean); + + for (const med of meds.length > 0 ? meds : [supervised.medicationName]) { + const key = `${apt.appointmentDate}|${med}`; + if (seen.has(key)) continue; + seen.add(key); + rows.push({ + doseNumber: rows.length + 1, + medicationName: med, + dateIso: apt.appointmentDate, + schedulingDateIso: apt.followUp?.nextAppointmentDate, + }); + } + } + + rows.sort((a, b) => a.dateIso.localeCompare(b.dateIso)); + return rows.map((row, index) => ({ ...row, doseNumber: index + 1 })); +} + +export function buildNeurologicalRows( + appointments: readonly HealthAppointment[] +): BookletNeurologicalAssessmentRow[] { + const rows: BookletNeurologicalAssessmentRow[] = []; + + for (const apt of appointments) { + const ans = apt.followUp?.neurologicalAssessment; + if (!ans) continue; + + const hasData = + ans.assessmentDate || + ans.gifEye || + ans.gifHand || + ans.gifFoot || + ans.highestGif || + ans.ompSum || + ans.conduct || + ans.ubs || + ans.reference; + + if (!hasData) continue; + + rows.push({ + contextLabel: `${appointmentTypeLabel(apt.type)} — ${formatAppointmentDatePt(apt.appointmentDate)}`, + assessmentDate: ans.assessmentDate || apt.appointmentDate, + gifEye: ans.gifEye || '—', + gifHand: ans.gifHand || '—', + gifFoot: ans.gifFoot || '—', + highestGif: ans.highestGif || '—', + ompSum: ans.ompSum || '—', + conduct: ans.conduct || '—', + ubs: ans.ubs || '—', + reference: ans.reference || '—', + }); + } + + return rows.sort((a, b) => b.assessmentDate.localeCompare(a.assessmentDate)); +} + +export function buildAppointmentRows( + appointments: readonly HealthAppointment[] +): BookletAppointmentRow[] { + return [...appointments] + .sort((a, b) => { + const byDate = b.appointmentDate.localeCompare(a.appointmentDate); + if (byDate !== 0) return byDate; + return (b.appointmentTime ?? '').localeCompare(a.appointmentTime ?? ''); + }) + .map((apt) => ({ + dateIso: apt.appointmentDate, + time: apt.appointmentTime || '—', + location: apt.location || '—', + typeLabel: appointmentTypeLabel(apt.type), + professional: apt.professional?.trim() || '—', + statusLabel: apt.performed ? 'Realizada' : 'Agendada', + })); +} + +export function appointmentTypeLabel(type: string): string { + return APPOINTMENT_TYPES.find((item) => item.value === type)?.label ?? (type || '—'); +} + +export function optionLabel( + options: readonly T[], + value: string +): string { + return options.find((o) => o.value === value)?.label ?? (value.trim() || '—'); +} + +export function yesNoLabel(value: YesNoChoice): string { + return optionLabel(YES_NO_OPTIONS, value); +} + +export function gifGradeLabel(value: GifGrade | string): string { + if (value === '0' || value === '1' || value === '2') { + return `Grau ${value}`; + } + return optionLabel(GIF_GRADE_OPTIONS, value as GifGrade); +} + +export function formatBookletDate(iso: string): string { + if (!iso?.trim()) return '—'; + const formatted = formatAppointmentDatePt(iso.slice(0, 10)); + return formatted || iso; +} + +function isoDateFromTimestamp(value: string | null | undefined): string | null { + if (!value?.trim()) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value.slice(0, 10) || null; + } + const y = parsed.getFullYear(); + const m = String(parsed.getMonth() + 1).padStart(2, '0'); + const d = String(parsed.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +export const BOOKLET_OPTION_HELPERS = { + nationality: (v: string) => optionLabel(NATIONALITY_OPTIONS, v), + raceColor: (v: string) => optionLabel(RACE_COLOR_OPTIONS, v), + sex: (v: string) => optionLabel(SEX_OPTIONS, v), + maritalStatus: (v: string) => optionLabel(MARITAL_STATUS_OPTIONS, v), + education: (v: string) => optionLabel(EDUCATION_OPTIONS, v), + genderIdentity: (v: string) => optionLabel(GENDER_IDENTITY_OPTIONS, v), + sexualOrientation: (v: string) => optionLabel(SEXUAL_ORIENTATION_OPTIONS, v), + classification: (v: string) => optionLabel(CLASSIFICATION_OPTIONS, v), + clinicalForm: (v: string) => optionLabel(CLINICAL_FORM_OPTIONS, v), + reactionType: (v: string) => optionLabel(REACTION_EPISODE_TYPE_OPTIONS, v), +}; diff --git a/frontend/src/app/features/profile/utils/booklet-html.builder.ts b/frontend/src/app/features/profile/utils/booklet-html.builder.ts new file mode 100644 index 0000000..9ca2acd --- /dev/null +++ b/frontend/src/app/features/profile/utils/booklet-html.builder.ts @@ -0,0 +1,877 @@ +import type { PatientBookletData } from '../models/patient-booklet.models'; +import type { PatientPersonalData, PatientTreatmentData } from '../models/patient-profile.models'; +import { BOOKLET_OPTION_HELPERS, formatBookletDate } from './booklet-export-data.utils'; +import { SUBSTITUTE_SCHEME_MEDICATION_OPTIONS } from '../models/patient-profile.models'; + +const PURPLE = '#5b3a7a'; +const PURPLE_DARK = '#45285c'; +const PURPLE_LIGHT = '#f3edf8'; +const PURPLE_BORDER = '#c9b8d9'; +const TEXT_MUTED = '#5c4d68'; + +function esc(value: string | null | undefined): string { + return (value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function display(value: string): string { + const trimmed = value?.trim(); + return trimmed ? esc(trimmed) : ''; +} + +function field(label: string, value: string): string { + return ` +
+
${esc(label)}
+
${display(value)}
+
`; +} + +function fieldInline(label: string, value: string): string { + return `
${esc(label)}${display(value)}
`; +} + +function check(checked: boolean, label: string): string { + return ``; +} + +function checkGroup(items: string): string { + return `
${items}
`; +} + +function section(title: string, body: string, extraClass = ''): string { + return ` +
+
+

${esc(title)}

+
+
${body}
+
`; +} + +function disclaimerBlock(): string { + return ` + `; +} + +function printHint(): string { + return ` + `; +} + +function documentHeader(generated: string, patientName: string): string { + return ` +
+
+ Pequi + Exportação de cartilha +
+

Caderneta de Saúde da Pessoa com Hanseníase

+

Síntese dos dados registrados no aplicativo

+
+ ${patientName ? `Paciente: ${esc(patientName)} · ` : ''}Gerado em ${esc(generated)} +
+
`; +} + +function documentFooter(): string { + return ` +
+

Exportado do aplicativo Pequi · Documento informativo — não oficial

+
`; +} + +function personalSection(p: PatientPersonalData): string { + const nationalityBr = p.nationality === 'brasileira'; + const nationalityForeign = p.nationality === 'estrangeira'; + const genderYes = p.wantsGenderIdentity === 'sim'; + const orientationYes = p.wantsSexualOrientation === 'sim'; + + const body = ` +
+ ${field('Nome completo', p.fullName)} + ${field('Nome social', p.socialName)} + ${field('Número do CPF', p.cpf)} + ${field('Número do Cartão SUS', p.susCard)} + ${field('Data de nascimento', formatBookletDate(p.birthDate))} + ${field('Estado civil', BOOKLET_OPTION_HELPERS.maritalStatus(p.maritalStatus))} +
+
+

Nacionalidade

+ ${checkGroup(`${check(nationalityBr, 'Brasileiro(a)')} ${check(nationalityForeign, 'Estrangeiro(a)')}`)} +
+
+

Raça/cor

+ ${checkGroup(` + ${check(p.raceColor === 'branca', 'Branca')} + ${check(p.raceColor === 'preta', 'Preta')} + ${check(p.raceColor === 'parda', 'Parda')} + ${check(p.raceColor === 'amarela', 'Amarela')} + ${check(p.raceColor === 'indigena', 'Indígena')} + `)} + ${p.raceColor === 'indigena' ? field('Se indígena, qual etnia?', p.indigenousEthnicity) : ''} +
+
+

Sexo

+ ${checkGroup(`${check(p.sex === 'feminino', 'Feminino')} ${check(p.sex === 'masculino', 'Masculino')}`)} +
+
+

Identidade de gênero

+

Deseja informar? ${genderYes ? 'Sim' : 'Não'}

+ ${ + genderYes + ? checkGroup(` + ${check(p.genderIdentity === 'homem_transexual', 'Homem transexual')} + ${check(p.genderIdentity === 'mulher_transexual', 'Mulher transexual')} + ${check(p.genderIdentity === 'travesti', 'Travesti')} + ${check(p.genderIdentity === 'outra', `Outra: ${p.genderIdentityOther || '—'}`)} + `) + : '' + } +
+
+

Orientação sexual

+

Deseja informar? ${orientationYes ? 'Sim' : 'Não'}

+ ${ + orientationYes + ? checkGroup(` + ${check(p.sexualOrientation === 'heterossexual', 'Heterossexual')} + ${check(p.sexualOrientation === 'bissexual', 'Bissexual')} + ${check(p.sexualOrientation === 'homossexual', 'Homossexual (gay/lésbica)')} + ${check(p.sexualOrientation === 'outra', `Outra: ${p.sexualOrientationOther || '—'}`)} + `) + : '' + } +
+
+
+ ${field('Endereço', p.address)} + ${field('Telefone celular', p.phone)} + ${field('e-mail', p.email)} + ${field('Escolaridade', BOOKLET_OPTION_HELPERS.education(p.education))} + ${field('Ocupação', p.occupation)} +
+
+

Unidade e equipe de saúde

+ ${field('Unidade de Atenção Primária', p.healthUnit)} +
+ ${field('ACS', p.acsName)} + ${field('Enfermeiro(a)', p.nurseName)} + ${field('Médico(a)', p.doctorName)} +
+
+
+

Emergência e informações clínicas gerais

+ ${field('Em situação de emergência, ligar para', p.emergencyContact)} +
+ ${field('Tipo sanguíneo', p.bloodType)} + ${field('Alergia a medicamento?', p.medicationAllergies)} +
+
`; + + return section('Dados pessoais', body); +} + +function clinicalRegisterSection(t: PatientTreatmentData): string { + const body = ` +
+
+ DIAGNÓSTICO + ${display(formatBookletDate(t.diagnosisDate))} +
+
+
${fieldInline('Nº CNS', t.cnsNumber)}
+
${fieldInline('Nº Sinan', t.sinanNumber)}
+
+ Classificação + ${checkGroup(`${check(t.classification === 'PB', 'PB')} ${check(t.classification === 'MB', 'MB')}`)} +
+
+ Início do tratamento + ${display(formatBookletDate(t.treatmentStartDate))} +
+
+
+
+ Forma clínica + ${checkGroup(` + ${check(t.clinicalForm === 'I', 'I')} + ${check(t.clinicalForm === 'T', 'T')} + ${check(t.clinicalForm === 'D', 'D')} + ${check(t.clinicalForm === 'V', 'V')} + `)} +
+
+ Baciloscopia + ${fieldInline('Data', formatBookletDate(t.baciloscopyDate))} + ${fieldInline('IB', t.baciloscopyIB)} + ${field('Exame de apoio', t.diagnosticSupportExam)} +
+
+ Avaliação GIF + ${checkGroup(` + ${check(t.gifAssessment === 'grau_0', 'Grau 0')} + ${check(t.gifAssessment === 'grau_1', 'Grau 1')} + ${check(t.gifAssessment === 'grau_2', 'Grau 2')} + `)} +
+
+
`; + + return section('Registro clínico', body, 'section-registry'); +} + +function reactionSection(t: PatientTreatmentData): string { + const meds = [ + ['Prednisona', t.prednisoneMgKg, 'mg/kg'], + ['AINE', t.aineMgDay, 'mg/dia'], + ['Talidomida', t.thalidomideMgDay, 'mg/dia'], + ['Pentoxifilina', t.pentoxifyllineMgDay, 'mg/dia'], + ] as const; + + const institutedRows = + t.institutedMedications.length > 0 + ? t.institutedMedications + .map( + (m) => + `
  • ${esc(m.name)} — ${esc(m.dose)} ${esc(m.unit)} / ${esc(m.frequency)}
  • ` + ) + .join('') + : meds + .filter(([, dose]) => dose.trim()) + .map(([name, dose, unit]) => `
  • ${esc(name)}: ${esc(dose)} ${esc(unit)}
  • `) + .join(''); + + const body = ` +
    +

    Episódio reacional por ocasião do diagnóstico

    + ${checkGroup(` + ${check(t.reactionEpisodeAtDiagnosis === 'sim', 'Sim')} + ${check(t.reactionEpisodeAtDiagnosis === 'nao', 'Não')} + `)} + ${ + t.reactionEpisodeAtDiagnosis === 'sim' + ? ` +
    + ${field('Tipo do episódio', BOOKLET_OPTION_HELPERS.reactionType(t.reactionEpisodeType))} + ${field('Data', formatBookletDate(t.reactionEpisodeDate))} +
    ` + : '' + } +

    Medicamentos instituídos

    +
      ${institutedRows || '
    • Nenhum registrado
    • '}
    + ${field('Outro medicamento', t.otherMedication)} + ${field('Outras condutas', t.otherConducts)} +
    `; + + return section('Episódio reacional e medicamentos', body); +} + +function supervisedDoseSection(data: PatientBookletData): string { + const rows = data.supervisedDoses; + const tableRows = Array.from({ length: 12 }, (_, index) => { + const row = rows[index]; + return ` + Dose ${index + 1} + + ${row ? `${esc(row.medicationName)}` : ''} +
    ${row ? esc(formatBookletDate(row.dateIso)) : '—'}
    + + ${row?.schedulingDateIso ? esc(formatBookletDate(row.schedulingDateIso)) : ''} + `; + }).join(''); + + const body = ` +

    Poliquimioterapia ou esquema substitutivo — registro de doses supervisionadas e aprazamentos.

    + + + + + + + + + ${tableRows} +
    Dose supervisionada (medicamento e data)Aprazamento
    + ${rows.length === 0 ? '

    Nenhuma dose supervisionada registrada no Pequi.

    ' : ''}`; + + return section('Dose supervisionada e aprazamento', body); +} + +function substituteSchemeSection(t: PatientTreatmentData): string { + const checks = SUBSTITUTE_SCHEME_MEDICATION_OPTIONS.map((opt) => { + const key = opt.key; + const checked = + (key === 'clofazimina' && t.schemeClofazimina) || + (key === 'ofloxacino' && t.schemeOfloxacino) || + (key === 'rifampicina' && t.schemeRifampicina) || + (key === 'minociclina' && t.schemeMinociclina) || + (key === 'dapsone' && t.schemeDapsone); + return check(checked, opt.label); + }).join(''); + + const body = ` +
    Esquema substitutivo
    + ${field('Data da mudança de esquema', formatBookletDate(t.substituteSchemeChangeDate))} +
    +

    Intolerância

    + ${checkGroup(` + ${check(t.intoleranceDapsone, 'Dapsona')} + ${check(t.intoleranceRifampicin, 'Rifampicina')} + ${check(t.intoleranceClofazimine, 'Clofazimina')} + `)} +
    +
    +

    Esquema medicamentoso

    + ${checkGroup(checks)} + ${field('Medicamento da dose mensal (atual)', t.currentDoseMedication)} +
    `; + + return section('Esquema substitutivo', body); +} + +function ansSection(data: PatientBookletData): string { + if (data.neurologicalAssessments.length === 0) { + return section( + 'Avaliação Neurológica Simplificada (ANS)', + '

    Nenhuma avaliação neurológica registrada no Pequi.

    ' + ); + } + + return data.neurologicalAssessments + .map((row, index) => { + const body = ` +

    ${esc(row.contextLabel)}

    +
    +
    + Avaliação + ${field('Data', formatBookletDate(row.assessmentDate))} + ${field('GIF — Olho', row.gifEye)} + ${field('GIF — Mão', row.gifHand)} + ${field('GIF — Pé', row.gifFoot)} +
    +
    + ${field('Maior GIF', row.highestGif)} + ${field('Soma OMP', row.ompSum)} + ${field('Conduta', row.conduct)} + ${field('UBS', row.ubs)} + ${field('Referência', row.reference)} +
    +
    `; + const title = + data.neurologicalAssessments.length > 1 + ? `Avaliação Neurológica Simplificada (ANS) — ${index + 1}` + : 'Avaliação Neurológica Simplificada (ANS)'; + return section(title, body, 'section-ans'); + }) + .join(''); +} + +function appointmentsSection(data: PatientBookletData): string { + const rows = data.appointments; + const body = + rows.length > 0 + ? ` + + + + + + + + + + + + + ${rows + .map( + (r) => ` + + + + + + + ` + ) + .join('')} + +
    DataHoraLocalConsulta / exameProfissionalStatus
    ${esc(formatBookletDate(r.dateIso))}${esc(r.time)}${esc(r.location)}${esc(r.typeLabel)}${esc(r.professional)}${esc(r.statusLabel)}
    ` + : '

    Nenhuma consulta ou exame registrado no Pequi.

    '; + + return section('Agenda de consultas e exames', body); +} + +function dischargeSection(t: PatientTreatmentData): string { + const body = ` +
    +

    Alta do tratamento da PQT / esquema substitutivo

    + ${field('Data da alta', formatBookletDate(t.pqtDischargeDate))} +
    +

    Classificação do GIF na alta

    + ${checkGroup(` + ${check(t.gifAssessmentAtDischarge === 'grau_0', 'GIF 0')} + ${check(t.gifAssessmentAtDischarge === 'grau_1', 'GIF 1')} + ${check(t.gifAssessmentAtDischarge === 'grau_2', 'GIF 2')} + `)} +
    +
    +

    Episódio reacional por ocasião da alta

    + ${checkGroup(` + ${check(t.reactionEpisodeAtDischarge === 'sim', 'Sim')} + ${check(t.reactionEpisodeAtDischarge === 'nao', 'Não')} + `)} + ${ + t.reactionEpisodeAtDischarge === 'sim' + ? `
    + ${field('Tipo', BOOKLET_OPTION_HELPERS.reactionType(t.reactionEpisodeTypeAtDischarge))} + ${field('Data', formatBookletDate(t.reactionEpisodeDateAtDischarge))} +
    ` + : '' + } +
    +
    + ${field('Prednisona (alta)', t.dischargePrednisoneMgKg)} + ${field('AINE (alta)', t.dischargeAineMgDay)} + ${field('Talidomida (alta)', t.dischargeThalidomideMgDay)} + ${field('Pentoxifilina (alta)', t.dischargePentoxifyllineMgDay)} + ${field('Outro medicamento', t.dischargeOtherMedication)} + ${field('Outras condutas', t.dischargeOtherConducts)} +
    +
    `; + + return section('Alta do tratamento', body); +} + +function bookletStyles(): string { + return ` + :root { + --purple: ${PURPLE}; + --purple-dark: ${PURPLE_DARK}; + --purple-light: ${PURPLE_LIGHT}; + --purple-border: ${PURPLE_BORDER}; + --text-muted: ${TEXT_MUTED}; + } + * { box-sizing: border-box; } + body { + font-family: "Segoe UI", system-ui, -apple-system, Arial, sans-serif; + color: var(--purple-dark); + margin: 0; + background: #faf8fc; + font-size: 13px; + line-height: 1.5; + } + .document { + max-width: 210mm; + margin: 0 auto; + background: #fff; + box-shadow: 0 4px 24px rgba(91, 58, 122, 0.08); + } + .doc-inner { padding: 20px 24px 32px; } + + .print-hint { + background: linear-gradient(135deg, #eef2ff 0%, #f3edf8 100%); + border-bottom: 1px solid var(--purple-border); + padding: 12px 24px; + font-size: 12px; + color: var(--purple-dark); + } + + .legal-notice { + display: flex; + gap: 14px; + background: #fff8e6; + border: 1.5px solid #e8c96a; + border-left: 5px solid #d4a017; + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 24px; + } + .legal-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: #d4a017; + color: #fff; + font-weight: 800; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + } + .legal-text { flex: 1; font-size: 12px; color: #4a3f20; line-height: 1.55; } + .legal-text strong { color: #3d3418; } + .legal-text p { margin: 0 0 8px; } + .legal-text p:last-child { margin-bottom: 0; } + + .doc-header { + text-align: center; + padding-bottom: 20px; + margin-bottom: 8px; + border-bottom: 3px double var(--purple); + } + .doc-brand { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-bottom: 12px; + } + .doc-app { + font-weight: 800; + font-size: 14px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--purple); + background: var(--purple-light); + padding: 4px 12px; + border-radius: 999px; + } + .doc-tag { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + .doc-header h1 { + margin: 0 0 6px; + font-size: 20px; + font-weight: 800; + color: var(--purple); + line-height: 1.25; + text-transform: none; + } + .doc-subtitle { + margin: 0 0 10px; + font-size: 13px; + color: var(--text-muted); + } + .doc-meta { + font-size: 12px; + color: var(--text-muted); + } + + .booklet-section { + margin-bottom: 28px; + page-break-inside: avoid; + } + .section-head { + margin-bottom: 12px; + } + .section-head h2 { + margin: 0; + font-size: 15px; + font-weight: 800; + color: #fff; + background: var(--purple); + padding: 8px 14px; + border-radius: 6px 6px 0 0; + letter-spacing: 0.02em; + } + .section-body { + border: 1.5px solid var(--purple-border); + border-top: none; + border-radius: 0 0 8px 8px; + padding: 16px; + background: #fff; + } + .section-lead { + margin: 0 0 12px; + font-size: 12px; + color: var(--text-muted); + } + + .field { margin-bottom: 12px; } + .field-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--purple); + margin-bottom: 3px; + } + .field-value { + font-size: 14px; + color: var(--purple-dark); + padding: 6px 0 4px; + border-bottom: 1.5px solid var(--purple-border); + min-height: 1.6em; + } + .field-value.inline { display: inline-block; min-width: 80px; margin-left: 6px; } + .field-inline { margin-bottom: 8px; } + .empty-val { color: #9ca3af; font-style: italic; } + + .field-grid { display: grid; gap: 4px 16px; } + .field-grid.two-col { grid-template-columns: 1fr 1fr; } + .field-grid.three-col { grid-template-columns: 1fr 1fr 1fr; } + .field-grid.nested { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--purple-border); } + + .subsection { margin: 14px 0; } + .subsection-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 700; + color: var(--purple); + } + .hint-line { margin: 0 0 8px; font-size: 12px; color: var(--text-muted); } + + .check-group { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + } + .check-item { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: default; + } + .check-box { + width: 14px; + height: 14px; + border: 1.5px solid var(--purple); + border-radius: 3px; + flex-shrink: 0; + position: relative; + background: #fff; + } + .check-box.checked::after { + content: ""; + position: absolute; + left: 3px; + top: 0px; + width: 5px; + height: 9px; + border: solid var(--purple); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + + .panel { + border: 1px solid var(--purple-border); + border-radius: 6px; + padding: 12px 14px; + margin-top: 12px; + background: #fdfcfe; + } + .panel.tinted { background: var(--purple-light); } + .panel-title { + margin: 0 0 10px; + font-weight: 700; + font-size: 12px; + color: var(--purple); + text-transform: uppercase; + letter-spacing: 0.03em; + } + .panel-question { + margin: 0 0 8px; + font-weight: 600; + font-size: 13px; + } + .divider { + height: 1px; + background: var(--purple-border); + margin: 16px 0; + } + + .registry-box { + border: 2px solid var(--purple); + border-radius: 6px; + overflow: hidden; + } + .registry-row.highlight { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + background: var(--purple-light); + border-bottom: 1px solid var(--purple-border); + } + .registry-label { + font-weight: 800; + font-size: 12px; + text-transform: uppercase; + color: var(--purple); + } + .registry-value { font-size: 15px; font-weight: 600; } + .registry-grid { + display: grid; + grid-template-columns: 1fr 1fr; + } + .registry-grid.three { grid-template-columns: 1fr 1fr 1fr; } + .registry-cell { + padding: 12px 14px; + border-top: 1px solid var(--purple-border); + border-right: 1px solid var(--purple-border); + } + .registry-cell:nth-child(2n) { border-right: none; } + .registry-grid.three .registry-cell:nth-child(2n) { border-right: 1px solid var(--purple-border); } + .registry-grid.three .registry-cell:last-child { border-right: none; } + + .scheme-banner { + text-align: center; + font-weight: 800; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--purple); + border: 1.5px solid var(--purple); + padding: 10px; + margin-bottom: 14px; + border-radius: 4px; + } + + .data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + .data-table th { + background: var(--purple); + color: #fff; + font-weight: 700; + text-align: left; + padding: 10px 8px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.02em; + } + .data-table td { + border: 1px solid var(--purple-border); + padding: 10px 8px; + vertical-align: top; + } + .data-table tbody tr:nth-child(even) { background: var(--purple-light); } + .dose-num { font-weight: 700; color: var(--purple); white-space: nowrap; width: 56px; } + .med-name { font-weight: 600; display: block; } + .dose-date { font-size: 11px; color: var(--text-muted); margin-top: 2px; } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + } + .status-pill.done { background: #dcfce7; color: #166534; } + .status-pill.pending { background: #fef3c7; color: #92400e; } + + .ans-context { + margin: 0 0 12px; + font-size: 12px; + color: var(--text-muted); + font-style: italic; + } + .ans-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + .ans-panel { + border: 1px solid var(--purple-border); + border-radius: 6px; + padding: 12px; + background: var(--purple-light); + } + + .med-list { + margin: 0; + padding-left: 18px; + } + .med-list li { margin-bottom: 4px; } + + .empty-note { + margin: 0; + padding: 16px; + text-align: center; + font-style: italic; + color: var(--text-muted); + background: var(--purple-light); + border-radius: 6px; + } + + .doc-footer { + margin-top: 32px; + padding-top: 14px; + border-top: 2px solid var(--purple-border); + text-align: center; + font-size: 11px; + color: var(--text-muted); + } + .doc-footer strong { color: var(--purple); } + + @media print { + body { background: #fff; } + .document { box-shadow: none; max-width: none; } + .doc-inner { padding: 10mm 12mm; } + .no-print { display: none !important; } + .booklet-section { page-break-inside: avoid; } + .legal-notice { break-inside: avoid; } + } + @media (max-width: 640px) { + .field-grid.two-col, + .field-grid.three-col, + .registry-grid, + .registry-grid.three, + .ans-grid { grid-template-columns: 1fr; } + .agenda-table { font-size: 10px; } + } + `; +} + +export function buildBookletHtml(data: PatientBookletData): string { + const generated = formatBookletDate(data.generatedAt.slice(0, 10)); + const patientName = data.personal.fullName || data.personal.socialName; + + return ` + + + + + Cartilha — Pequi + + + +
    + ${printHint()} +
    + ${documentHeader(generated, patientName)} + ${disclaimerBlock()} + ${personalSection(data.personal)} + ${clinicalRegisterSection(data.treatment)} + ${reactionSection(data.treatment)} + ${supervisedDoseSection(data)} + ${substituteSchemeSection(data.treatment)} + ${ansSection(data)} + ${appointmentsSection(data)} + ${dischargeSection(data.treatment)} + ${disclaimerBlock()} + ${documentFooter()} +
    +
    + +`; +} diff --git a/frontend/src/app/layout/app-shell-component/app-shell-component.ts b/frontend/src/app/layout/app-shell-component/app-shell-component.ts index ca18136..7b54e25 100644 --- a/frontend/src/app/layout/app-shell-component/app-shell-component.ts +++ b/frontend/src/app/layout/app-shell-component/app-shell-component.ts @@ -6,6 +6,7 @@ import { CommonModule } from '@angular/common'; import { filter } from 'rxjs/operators'; import { Menu } from '../../components/menu/menu'; import { AppHeader, type AppHeaderLayout } from '../../components/app-header/app-header'; +import { PatientProfileService } from '../../features/profile/services/patient-profile.service'; @Component({ selector: 'app-shell', @@ -15,6 +16,7 @@ import { AppHeader, type AppHeaderLayout } from '../../components/app-header/app }) export class AppShellComponent { private readonly router = inject(Router); + private readonly profileService = inject(PatientProfileService); isMenuCollapsed = false; @@ -23,6 +25,10 @@ export class AppShellComponent { readonly quietNotificationBell = signal(false); constructor() { + this.profileService.syncLoginEmailFromAuth(); + this.profileService.syncPersonalFromApi().subscribe({ error: () => undefined }); + this.profileService.syncTreatmentFromApi().subscribe({ error: () => undefined }); + this.router.events .pipe( filter((e): e is NavigationEnd => e instanceof NavigationEnd), From f7f1b568335298aefbc873f7a5d017183c3fab34 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Sat, 6 Jun 2026 09:48:19 -0300 Subject: [PATCH 57/69] =?UTF-8?q?PEQ-159:=20adiciona=20configura=C3=A7?= =?UTF-8?q?=C3=A3o=20do=20ambiente=20de=20teste=20e=20configura=C3=A7?= =?UTF-8?q?=C3=B5es=20de=20compila=C3=A7=C3=A3o=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(staging): add staging environment configuration and build settings * fix(docs): correct note formatting in README --- README.md | 2 +- frontend/angular.json | 9 +++++++++ frontend/src/environments/environment.staging.ts | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 frontend/src/environments/environment.staging.ts diff --git a/README.md b/README.md index 618cecc..92a1573 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Pequi nasceu para reduzir esse abandono. O aplicativo permite que pacientes regi O projeto é desenvolvido como software de código aberto para unidades de saúde pública e organizações que atuam no combate à hanseníase no Brasil. -> [!IMPORTANTE] +> [!NOTE] > O objetivo do Pequi é apoiar o acompanhamento de pacientes com hanseníase, mas ele **não substitui avaliação médica profissional**. A plataforma foi projetada para auxiliar a adesão ao tratamento, o monitoramento clínico e a comunicação entre equipes de saúde, sempre respeitando princípios de privacidade, segurança da informação e conformidade com a LGPD. --- diff --git a/frontend/angular.json b/frontend/angular.json index a598d22..602883b 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -51,6 +51,15 @@ ], "outputHashing": "all" }, + "staging": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.staging.ts" + } + ], + "outputHashing": "all" + }, "development": { "optimization": false, "extractLicenses": false, diff --git a/frontend/src/environments/environment.staging.ts b/frontend/src/environments/environment.staging.ts new file mode 100644 index 0000000..7977d68 --- /dev/null +++ b/frontend/src/environments/environment.staging.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: '__API_URL_STAGING__', +}; From 32bbaa41ef84c6cadc2ba4adc991d67e0bbc317c Mon Sep 17 00:00:00 2001 From: Sarah Domingos <92494941+sarahdomingos@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:32:19 -0300 Subject: [PATCH 58/69] =?UTF-8?q?feat:=20tela=20de=20checkin=20com=20integ?= =?UTF-8?q?ra=C3=A7=C3=A3o=20funcionando=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkin-step-feeling-component.ts | 2 +- .../checkin-step-symptoms-component.html | 4 +- .../checkin-step-symptoms-component.ts | 102 +------ .../src/app/features/checkin/checkin.html | 11 +- .../src/app/features/checkin/checkin.spec.ts | 265 +++++++++++++++--- frontend/src/app/features/checkin/checkin.ts | 95 +++++-- .../features/checkin/models/checkin.models.ts | 16 +- .../checkin/services/checkin.service.ts | 125 ++++++++- 8 files changed, 420 insertions(+), 200 deletions(-) diff --git a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts index 8b83dc8..4271ac7 100644 --- a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts +++ b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts @@ -31,7 +31,7 @@ export class CheckinStepFeelingComponent { bars: 5, }, { - value: 'muito-bem', + value: 'good', label: 'Muito Bem', emoji: '🙂', color: 'bg-[#5C9B7B]', diff --git a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html index d98f98a..5d513e8 100644 --- a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html +++ b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html @@ -33,7 +33,7 @@

    +

    diff --git a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts index 5dcde43..92c1bed 100644 --- a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts +++ b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts @@ -2,12 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; -type SymptomOption = { - value: string; - label: string; - selectedClass: string; - unselectedClass: string; -}; +import type { SymptomOption } from '../../features/checkin/models/checkin.models'; @Component({ selector: 'app-checkin-step-symptoms-component', @@ -18,102 +13,11 @@ type SymptomOption = { }) export class CheckinStepSymptomsComponent { @Input({ required: true }) form!: FormGroup; + @Input({ required: true }) symptoms: SymptomOption[] = []; + @Input() loading = false; readonly noSymptomsValue = 'nenhum sintoma'; - symptoms: SymptomOption[] = [ - { - value: 'nenhum sintoma', - label: 'Nenhum sintoma hoje', - selectedClass: 'bg-[#C0B9FF] border-[#C0B9FF] text-white', - unselectedClass: 'bg-[#4338CA] border-[#4338CA] opacity-80 text-white', - }, - { - value: 'dormencia', - label: 'Dormência', - selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', - unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', - }, - { - value: 'feridas na pele', - label: 'Feridas na pele', - selectedClass: 'bg-[#CFF2D9] border-[#A9E2BC] text-[#2F6B45]', - unselectedClass: 'bg-[#EEF9F1] border-[#CBEBD4] text-[#44403C]', - }, - { - value: 'pele seca', - label: 'Pele seca', - selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', - unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', - }, - { - value: 'formigamento', - label: 'Formigamento', - selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', - unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', - }, - { - value: 'fraqueza muscular', - label: 'Fraqueza muscular', - selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', - unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', - }, - { - value: 'nodulos', - label: 'Nódulos', - selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', - unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', - }, - { - value: 'problemas de visao', - label: 'Problemas de visão', - selectedClass: 'bg-[#ECE9FF] border-[#D3CDF8] text-[#4B3B8F]', - unselectedClass: 'bg-[#F7F5FF] border-[#E2DCF8] text-[#44403C]', - }, - { - value: 'vermelhidao', - label: 'Vermelhidão', - selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', - unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', - }, - { - value: 'mudança de cor da pele', - label: 'Mudança de cor da pele', - selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', - unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', - }, - { - value: 'coceira', - label: 'Coceira', - selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', - unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', - }, - { - value: 'suor frio', - label: 'Suor frio', - selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', - unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', - }, - { - value: 'escamação', - label: 'Escamação', - selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', - unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', - }, - { - value: 'sangramento', - label: 'Sangramento', - selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', - unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', - }, - { - value: 'perda de sensibilidade na pele', - label: 'Perda de sensibilidade na pele', - selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', - unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', - }, - ]; - get selectedSymptoms(): string[] { return this.form.get('selectedSymptoms')?.value ?? []; } diff --git a/frontend/src/app/features/checkin/checkin.html b/frontend/src/app/features/checkin/checkin.html index 19ea338..82a8efd 100644 --- a/frontend/src/app/features/checkin/checkin.html +++ b/frontend/src/app/features/checkin/checkin.html @@ -25,11 +25,12 @@

    Check-in

    [form]="feelingForm" > - - + { let fixture: ComponentFixture; let component: CheckinComponent; + let router: { navigate: ReturnType }; let checkinService: { listSymptoms: ReturnType; submit: ReturnType; resolveSymptomIds: ReturnType; + buildSymptomOptions: ReturnType; + }; + let medicationDataService: { + getMedicationChecklist: ReturnType; }; let toastService: { success: ReturnType; @@ -69,10 +77,46 @@ describe(CheckinComponent.name, () => { }; const mockSymptoms = [ - { id: 'symptom-1', name: 'Nenhum sintoma hoje', category: 'systemic' }, - { id: 'symptom-2', name: 'Dormência', category: 'neurological' }, + { + id: 'symptom-none', + name: 'Nenhum sintoma', + category: 'systemic', + description: 'Sem sintomas', + }, + { + id: 'symptom-1', + name: 'Dormência', + category: 'neurological', + description: 'Dormência', + }, ]; + const mockSymptomOptions = [ + { + id: 'symptom-none', + value: 'Nenhum sintoma', + label: 'Nenhum sintoma hoje', + category: 'systemic', + description: 'Sem sintomas', + selectedClass: 'selected-none', + unselectedClass: 'unselected-none', + }, + { + id: 'symptom-1', + value: 'Dormência', + label: 'Dormência', + category: 'neurological', + description: 'Dormência', + selectedClass: 'selected-default', + unselectedClass: 'unselected-default', + }, + ]; + + const mockChecklist = { + institutedMedications: [], + currentDoseMedication: null, + }; + const getByTestId = (testId: string) => fixture.debugElement.query(By.css(`[data-testid="${testId}"]`)); @@ -86,15 +130,25 @@ describe(CheckinComponent.name, () => { checkinService = { listSymptoms: vi.fn(() => of(mockSymptoms)), + buildSymptomOptions: vi.fn(() => mockSymptomOptions), submit: vi.fn(() => of({ id: 'checkin-1' })), resolveSymptomIds: vi.fn((selected: string[]) => { if (selected.includes('nenhum sintoma')) { + return ['symptom-none']; + } + + if (selected.includes('Dormência') || selected.includes('dormencia')) { return ['symptom-1']; } - return ['symptom-2']; + + return ['symptom-1']; }), }; + medicationDataService = { + getMedicationChecklist: vi.fn(() => of(mockChecklist)), + }; + toastService = { success: vi.fn(), error: vi.fn(), @@ -105,6 +159,7 @@ describe(CheckinComponent.name, () => { providers: [ { provide: Router, useValue: router }, { provide: CheckinService, useValue: checkinService }, + { provide: MedicationDataService, useValue: medicationDataService }, { provide: ToastService, useValue: toastService }, ], }) @@ -137,6 +192,19 @@ describe(CheckinComponent.name, () => { expect(component).toBeTruthy(); }); + it('should load symptoms and symptom options on init', () => { + expect(checkinService.listSymptoms).toHaveBeenCalled(); + expect(checkinService.buildSymptomOptions).toHaveBeenCalledWith(mockSymptoms); + expect(component.symptomCatalog()).toEqual(mockSymptoms); + expect(component.symptomOptions()).toEqual(mockSymptomOptions); + expect(component.symptomsLoading()).toBe(false); + }); + + it('should load medication reminder when there are no medications', () => { + expect(medicationDataService.getMedicationChecklist).toHaveBeenCalled(); + expect(component.medicationReminder()).toContain('Cadastre medicamentos e frequência'); + }); + it('should start on step 1', () => { expect(component.currentStep()).toBe(1); expect(component.currentStepNumber()).toBe(1); @@ -165,12 +233,6 @@ describe(CheckinComponent.name, () => { expect(prevButton.disabled).toBe(true); }); - it('should disable next button when current step is invalid', () => { - const [, nextButton] = getButtons(); - expect(component.isCurrentStepInvalid()).toBe(true); - expect(nextButton.disabled).toBe(true); - }); - it('should expose subforms correctly', () => { expect(component.feelingForm).toBeTruthy(); expect(component.symptomsForm).toBeTruthy(); @@ -245,7 +307,7 @@ describe(CheckinComponent.name, () => { it('should advance from step 2 to step 3 when symptoms form has regular symptoms', () => { component.currentStep.set(2); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.nextStep(); @@ -282,7 +344,7 @@ describe(CheckinComponent.name, () => { }); it('should keep intensity required when there are symptoms', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['headache']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); const scaleControl = component.intensityForm.get('scale'); @@ -313,14 +375,14 @@ describe(CheckinComponent.name, () => { component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); expect(scaleControl?.hasValidator(Validators.required)).toBe(false); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); expect(scaleControl?.hasValidator(Validators.required)).toBe(true); expect(component.intensityForm.invalid).toBe(true); }); it('should not advance from step 3 when intensity is required and invalid', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.currentStep.set(3); fixture.detectChanges(); @@ -331,7 +393,7 @@ describe(CheckinComponent.name, () => { }); it('should advance from step 3 to step 4 when intensity is valid', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.currentStep.set(3); component.intensityForm.get('scale')?.setValue(4); @@ -341,7 +403,7 @@ describe(CheckinComponent.name, () => { }); it('should go back from step 4 to step 3 in regular flow', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.currentStep.set(4); component.prevStep(); @@ -400,97 +462,208 @@ describe(CheckinComponent.name, () => { component.currentStep.set(3); fixture.detectChanges(); - const [, nextButton] = getButtons(); + const buttons = getButtons(); + const nextButton = buttons[1]; expect(nextButton.textContent?.trim()).toBe('Próximo'); }); - it('should show "Enviar registro" button on last step', () => { + it('should show "Enviar formulário" button on last step', () => { component.currentStep.set(4); fixture.detectChanges(); - const [, submitButton] = getButtons(); - expect(submitButton.textContent?.trim()).toBe('Enviar registro'); + const buttons = getButtons(); + const submitButton = buttons[1]; + expect(submitButton.textContent?.trim()).toBe('Enviar formulário'); }); - it('should keep next button disabled on invalid required steps', () => { + it('should keep next button enabled because template does not bind disabled state', () => { component.currentStep.set(1); - expect(component.feelingForm.invalid).toBe(true); + fixture.detectChanges(); - component.currentStep.set(2); - expect(component.symptomsForm.invalid).toBe(true); + const buttons = getButtons(); + const nextButton = buttons[1]; - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); - component.currentStep.set(3); - expect(component.intensityForm.invalid).toBe(true); + expect(component.isCurrentStepInvalid()).toBe(true); + expect(nextButton.disabled).toBe(false); }); it('should enable submit button on step 4 because details is optional', () => { component.currentStep.set(4); fixture.detectChanges(); - const [, submitButton] = getButtons(); + const buttons = getButtons(); + const submitButton = buttons[1]; + expect(component.isCurrentStepInvalid()).toBe(false); expect(submitButton.disabled).toBe(false); }); + it('should not submit when symptoms are still loading', () => { + component.symptomsLoading.set(true); + + component.submit(); + + expect(checkinService.submit).not.toHaveBeenCalled(); + expect(toastService.error).toHaveBeenCalledWith( + 'Os sintomas ainda estão carregando', + 'Aguarde alguns instantes e tente novamente.', + ); + }); + + it('should not submit when symptom catalog is empty', () => { + component.symptomsLoading.set(false); + component.symptomCatalog.set([]); + + component.submit(); + + expect(checkinService.submit).not.toHaveBeenCalled(); + expect(toastService.error).toHaveBeenCalledWith( + 'Os sintomas ainda estão carregando', + 'Aguarde alguns instantes e tente novamente.', + ); + }); + it('should not submit when the full form is invalid', () => { + component.symptomsLoading.set(false); + component.submit(); + expect(checkinService.submit).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); }); it('should mark full form as touched when submit is called with invalid form', () => { + component.symptomsLoading.set(false); + component.submit(); expect(component.form.touched).toBe(true); }); - it('should submit and navigate to home when form is valid in regular flow', () => { + it('should not submit when resolveSymptomIds returns empty array', () => { + checkinService.resolveSymptomIds.mockReturnValue([]); + component.feelingForm.get('mood')?.setValue('good'); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(2); + + component.submit(); + + expect(checkinService.submit).not.toHaveBeenCalled(); + expect(toastService.error).toHaveBeenCalledWith( + 'Não foi possível identificar os sintomas', + 'Confira se o catálogo foi carregado corretamente e tente novamente.', + ); + }); + + it('should submit and navigate to medication when form is valid in regular flow', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.intensityForm.get('scale')?.setValue(1); component.detailsForm.get('notes')?.setValue('feeling well'); component.submit(); - expect(checkinService.submit).toHaveBeenCalled(); + expect(checkinService.resolveSymptomIds).toHaveBeenCalledWith( + ['Dormência'], + mockSymptoms, + ); + expect(checkinService.submit).toHaveBeenCalledWith({ + mood: 'good', + symptom_intensity: 1, + symptom_ids: ['symptom-1'], + general_notes: 'feeling well', + }); expect(toastService.success).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(router.navigate).toHaveBeenCalledWith(['/medication']); }); - it('should submit and navigate to home when "nenhum sintoma" skips intensity', () => { + it('should submit and navigate to medication when "nenhum sintoma" skips intensity', () => { component.feelingForm.get('mood')?.setValue('good'); component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); component.detailsForm.get('notes')?.setValue('sem sintomas hoje'); component.submit(); + expect(checkinService.submit).toHaveBeenCalledWith({ + mood: 'good', + symptom_intensity: 0, + symptom_ids: ['symptom-none'], + general_notes: 'sem sintomas hoje', + }); + expect(toastService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/medication']); + }); + + it('should trim notes before submitting', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(5); + component.detailsForm.get('notes')?.setValue(' observação '); + + component.submit(); + expect(checkinService.submit).toHaveBeenCalledWith( expect.objectContaining({ - mood: 'good', - symptom_intensity: 0, - symptom_ids: ['symptom-1'], + general_notes: 'observação', }), ); - expect(router.navigate).toHaveBeenCalledWith(['/home']); }); - it('should submit payload with only selectedSymptoms inside symptoms object', () => { - component.feelingForm.get('mood')?.setValue('sad'); - component.symptomsForm.get('selectedSymptoms')?.setValue(['nausea']); - component.symptomsForm.get('customSymptom')?.setValue('other symptom'); + it('should submit null notes when details notes is empty', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.intensityForm.get('scale')?.setValue(5); - component.detailsForm.get('notes')?.setValue('extra notes'); + component.detailsForm.get('notes')?.setValue(' '); component.submit(); + + expect(checkinService.submit).toHaveBeenCalledWith( + expect.objectContaining({ + general_notes: null, + }), + ); }); - it('should submit payload with null intensity when "nenhum sintoma" is selected', () => { + it('should show conflict toast when api returns 409', () => { + checkinService.submit.mockReturnValue( + throwError(() => ({ + status: 409, + error: { detail: 'Já existe um check-in registrado para hoje.' }, + })), + ); + component.feelingForm.get('mood')?.setValue('good'); - component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); - component.detailsForm.get('notes')?.setValue('sem observações'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(5); component.submit(); + + expect(toastService.error).toHaveBeenCalledWith( + 'Você já registrou seu check-in hoje', + 'Já existe um check-in registrado para hoje.', + ); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should show generic error toast when api returns non-409 error', () => { + checkinService.submit.mockReturnValue( + throwError(() => ({ + status: 500, + })), + ); + + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(5); + + component.submit(); + + expect(toastService.error).toHaveBeenCalledWith( + 'Erro ao enviar check-in', + 'Tente novamente em instantes.', + ); + expect(router.navigate).not.toHaveBeenCalled(); }); it('should follow the regular flow without skipping when there are symptoms', () => { @@ -498,7 +671,7 @@ describe(CheckinComponent.name, () => { component.nextStep(); expect(component.currentStep()).toBe(2); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.nextStep(); expect(component.currentStep()).toBe(3); diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index c3084ed..d486818 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -1,12 +1,14 @@ import { CommonModule } from '@angular/common'; import { Component, + WritableSignal, computed, effect, inject, + OnDestroy, OnInit, signal, - WritableSignal, + Injector, } from '@angular/core'; import { FormBuilder, @@ -15,12 +17,14 @@ import { Validators, } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { CheckinStepDetailsComponent } from '../../components/checkin-step-details-component/checkin-step-details-component'; import { CheckinStepFeelingComponent } from '../../components/checkin-step-feeling-component/checkin-step-feeling-component'; -import { CheckinStepSymptomsComponent } from '../../components/checkin-step-symptoms-component/checkin-step-symptoms-component'; import { CheckinStepIntensityComponent } from '../../components/checkin-step-intensity-component/checkin-step-intensity-component'; -import { CheckinStepDetailsComponent } from '../../components/checkin-step-details-component/checkin-step-details-component'; +import { CheckinStepSymptomsComponent } from '../../components/checkin-step-symptoms-component/checkin-step-symptoms-component'; import { ToastService } from '../../components/toast/toast.service'; -import type { SymptomResponse } from './models/checkin.models'; +import type { SymptomOption, SymptomResponse } from './models/checkin.models'; import { CheckinService } from './services/checkin.service'; import { MedicationDataService } from '../medication/services/medication-data.service'; @@ -44,17 +48,20 @@ type StepItem = { templateUrl: './checkin.html', styleUrl: './checkin.css', }) -export class CheckinComponent implements OnInit { +export class CheckinComponent implements OnInit, OnDestroy { private readonly fb = inject(FormBuilder); private readonly router = inject(Router); private readonly checkinService = inject(CheckinService); private readonly medicationData = inject(MedicationDataService); private readonly toast = inject(ToastService); + private readonly injector = inject(Injector); private readonly NO_SYMPTOM_VALUE = 'nenhum sintoma'; readonly symptomCatalog = signal([]); + readonly symptomOptions = signal([]); readonly submitting = signal(false); + readonly symptomsLoading = signal(true); readonly medicationReminder = signal(null); steps: StepItem[] = [ @@ -88,15 +95,21 @@ export class CheckinComponent implements OnInit { const step = this.currentStep(); return (step / this.steps.length) * 100; }); - stepStatusSubscription: any; - symptomsSelectionSubscription: import("rxjs").Subscription | undefined; - constructor() {} + private stepStatusSubscription?: Subscription; + private symptomsSelectionSubscription?: Subscription; ngOnInit(): void { + this.setupIntensityConditionalValidation(); + this.checkinService.listSymptoms().subscribe({ - next: symptoms => this.symptomCatalog.set(symptoms), + next: symptoms => { + this.symptomCatalog.set(symptoms); + this.symptomOptions.set(this.checkinService.buildSymptomOptions(symptoms)); + this.symptomsLoading.set(false); + }, error: () => { + this.symptomsLoading.set(false); this.toast.error( 'Erro ao carregar sintomas', 'Verifique sua conexão e tente novamente.', @@ -105,15 +118,18 @@ export class CheckinComponent implements OnInit { }); this.medicationData.getMedicationChecklist().subscribe({ - next: (checklist) => { + next: checklist => { const total = - checklist.institutedMedications.length + (checklist.currentDoseMedication ? 1 : 0); + checklist.institutedMedications.length + + (checklist.currentDoseMedication ? 1 : 0); + if (total === 0) { this.medicationReminder.set( 'Cadastre medicamentos e frequência em Meu tratamento para ver os lembretes em Remédios.', ); return; } + this.medicationReminder.set( `Você tem ${total} medicamento(s) no plano. Em Remédios, os avisos seguem a frequência de cada um.`, ); @@ -122,6 +138,11 @@ export class CheckinComponent implements OnInit { }); } + ngOnDestroy(): void { + this.stepStatusSubscription?.unsubscribe(); + this.symptomsSelectionSubscription?.unsubscribe(); + } + get currentStepNumber(): WritableSignal { return this.currentStep; } @@ -213,6 +234,14 @@ export class CheckinComponent implements OnInit { } submit(): void { + if (this.symptomsLoading() || this.symptomCatalog().length === 0) { + this.toast.error( + 'Os sintomas ainda estão carregando', + 'Aguarde alguns instantes e tente novamente.', + ); + return; + } + if (this.form.invalid) { this.form.markAllAsTouched(); return; @@ -221,6 +250,7 @@ export class CheckinComponent implements OnInit { const rawValue = this.form.getRawValue(); const selectedSymptoms = rawValue.symptoms.selectedSymptoms ?? []; const noSymptomsSelected = selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); + const symptomIds = this.checkinService.resolveSymptomIds( selectedSymptoms, this.symptomCatalog(), @@ -229,7 +259,7 @@ export class CheckinComponent implements OnInit { if (symptomIds.length === 0) { this.toast.error( 'Não foi possível identificar os sintomas', - 'Aguarde o carregamento do catálogo ou selecione outra opção.', + 'Confira se o catálogo foi carregado corretamente e tente novamente.', ); return; } @@ -246,6 +276,8 @@ export class CheckinComponent implements OnInit { general_notes: rawValue.details.notes?.trim() || null, }; + console.log('ISSO QUE O FRONT MANDA: ',payload); + this.submitting.set(true); this.checkinService.submit(payload).subscribe({ next: () => { @@ -256,13 +288,22 @@ export class CheckinComponent implements OnInit { ); void this.router.navigate(['/medication']); }, - error: () => { + error: (errorResponse) => { this.submitting.set(false); + + if (errorResponse.status === 409) { + this.toast.error( + 'Você já registrou seu check-in hoje', + errorResponse.error?.detail ?? 'Você já registrou seu check-in diário.', + ); + return; + } + this.toast.error( 'Erro ao enviar check-in', 'Tente novamente em instantes.', ); - }, + } }); } @@ -292,29 +333,27 @@ export class CheckinComponent implements OnInit { return selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); } - private setupCurrentStepValidationWatcher(): void { - effect(() => { - const step = this.currentStep(); - const currentGroup = this.getStepForm(step); + private readonly currentStepValidationEffect = effect(() => { + const step = this.currentStep(); + const currentGroup = this.getStepForm(step); - this.stepStatusSubscription?.unsubscribe(); - this.isCurrentStepInvalid.set(currentGroup.invalid); + this.stepStatusSubscription?.unsubscribe(); + this.isCurrentStepInvalid.set(currentGroup.invalid); - this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { - this.isCurrentStepInvalid.set(currentGroup.invalid); - }); + this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { + this.isCurrentStepInvalid.set(currentGroup.invalid); }); - } + }, { injector: this.injector }); private setupIntensityConditionalValidation(): void { const selectedSymptomsControl = this.symptomsForm.get('selectedSymptoms'); - const intensityScaleControl = this.intensityForm.get('scale'); this.applyIntensityValidation(); - this.symptomsSelectionSubscription = selectedSymptomsControl?.valueChanges.subscribe(() => { - this.applyIntensityValidation(); - }); + this.symptomsSelectionSubscription = + selectedSymptomsControl?.valueChanges.subscribe(() => { + this.applyIntensityValidation(); + }); } private applyIntensityValidation(): void { diff --git a/frontend/src/app/features/checkin/models/checkin.models.ts b/frontend/src/app/features/checkin/models/checkin.models.ts index 1c0980d..4e6b42f 100644 --- a/frontend/src/app/features/checkin/models/checkin.models.ts +++ b/frontend/src/app/features/checkin/models/checkin.models.ts @@ -2,23 +2,31 @@ export interface SymptomResponse { id: string; name: string; category: string; - description?: string; + description: string | null; } export interface CheckinCreate { mood: string; symptom_intensity: number; symptom_ids: string[]; - general_notes?: string | null; + general_notes: string | null; } export interface CheckinResponse { id: string; - patient_id: string; mood: string; symptom_intensity: number; symptom_ids: string[]; general_notes: string | null; - checked_in_at: string; created_at: string; } + +export type SymptomOption = { + id: string; + value: string; + label: string; + category: string; + description: string | null; + selectedClass: string; + unselectedClass: string; +}; \ No newline at end of file diff --git a/frontend/src/app/features/checkin/services/checkin.service.ts b/frontend/src/app/features/checkin/services/checkin.service.ts index 278b139..c501fed 100644 --- a/frontend/src/app/features/checkin/services/checkin.service.ts +++ b/frontend/src/app/features/checkin/services/checkin.service.ts @@ -3,10 +3,20 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { environment } from '../../../../environments/environment'; -import type { CheckinCreate, CheckinResponse, SymptomResponse } from '../models/checkin.models'; +import type { + CheckinCreate, + CheckinResponse, + SymptomOption, + SymptomResponse, +} from '../models/checkin.models'; const NO_SYMPTOM_VALUE = 'nenhum sintoma'; +type SymptomStyle = { + selectedClass: string; + unselectedClass: string; +}; + @Injectable({ providedIn: 'root', }) @@ -14,6 +24,69 @@ export class CheckinService { private readonly http = inject(HttpClient); private readonly apiUrl = environment.apiUrl; + private readonly symptomStyleMap: Record = { + 'nenhum sintoma': { + selectedClass: 'bg-[#C0B9FF] border-[#C0B9FF] text-white', + unselectedClass: 'bg-[#4338CA] border-[#4338CA] opacity-80 text-white', + }, + dormencia: { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }, + 'feridas na pele': { + selectedClass: 'bg-[#CFF2D9] border-[#A9E2BC] text-[#2F6B45]', + unselectedClass: 'bg-[#EEF9F1] border-[#CBEBD4] text-[#44403C]', + }, + 'pele seca': { + selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', + unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', + }, + formigamento: { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }, + 'fraqueza muscular': { + selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', + unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', + }, + nodulos: { + selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', + unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', + }, + 'problemas de visao': { + selectedClass: 'bg-[#ECE9FF] border-[#D3CDF8] text-[#4B3B8F]', + unselectedClass: 'bg-[#F7F5FF] border-[#E2DCF8] text-[#44403C]', + }, + vermelhidao: { + selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', + unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', + }, + 'mudanca de cor da pele': { + selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', + unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', + }, + coceira: { + selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', + unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', + }, + 'suor frio': { + selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', + unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', + }, + escamacao: { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }, + sangramento: { + selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', + unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', + }, + 'perda de sensibilidade na pele': { + selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', + unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', + }, + }; + listSymptoms(): Observable { return this.http.get(`${this.apiUrl}/v1/symptoms`); } @@ -22,26 +95,48 @@ export class CheckinService { return this.http.post(`${this.apiUrl}/v1/checkins`, payload); } - resolveSymptomIds(selectedSlugs: string[], catalog: SymptomResponse[]): string[] { - if (selectedSlugs.includes(NO_SYMPTOM_VALUE)) { - const noneSymptom = catalog.find(symptom => - this.normalize(symptom.name).includes('nenhum'), + resolveSymptomIds(selectedNames: string[], catalog: SymptomResponse[]): string[] { + if (!catalog.length) { + return []; + } + + if (selectedNames.includes(NO_SYMPTOM_VALUE)) { + const noneSymptom = catalog.find( + symptom => this.normalize(symptom.name) === NO_SYMPTOM_VALUE, ); + return noneSymptom ? [noneSymptom.id] : []; } - const ids: string[] = []; + return selectedNames + .map(selectedName => { + const normalizedName = this.normalize(selectedName); - for (const slug of selectedSlugs) { - const normalizedSlug = this.normalize(slug); - const match = catalog.find(symptom => this.normalize(symptom.name) === normalizedSlug); + return catalog.find( + symptom => this.normalize(symptom.name) === normalizedName, + )?.id; + }) + .filter((id): id is string => !!id); + } - if (match) { - ids.push(match.id); - } - } + buildSymptomOptions(catalog: SymptomResponse[]): SymptomOption[] { + return catalog.map(symptom => { + const normalizedName = this.normalize(symptom.name); + const style = this.symptomStyleMap[normalizedName] ?? { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }; - return ids; + return { + id: symptom.id, + value: symptom.name, + label: symptom.name === 'Nenhum sintoma' ? 'Nenhum sintoma hoje' : symptom.name, + category: symptom.category, + description: symptom.description, + selectedClass: style.selectedClass, + unselectedClass: style.unselectedClass, + }; + }); } private normalize(value: string): string { @@ -52,4 +147,4 @@ export class CheckinService { .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' '); } -} +} \ No newline at end of file From 3270ddd7a50118523460b761e32a550b44d5c790 Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Mon, 8 Jun 2026 21:47:16 -0300 Subject: [PATCH 59/69] PEQ-160: refatora M3 para centralizar paciente (#69) * feat: add journey endpoint and related use case for patient treatment journey - Introduced a new router for patient journey with endpoint /v1/journey. - Implemented GetPatientJourneyUseCase to fetch the treatment journey for authenticated patients. - Added JourneyService to calculate treatment progress, timeline, and summary. - Updated schemas for journey response, including events and monthly summaries. - Refactored treatment and dose models to remove unnecessary fields and dependencies on health professionals. - Adjusted treatment creation and dose registration logic to allow patient self-management. - Enhanced adherence and treatment retrieval use cases to restrict access to patient-owned data. * feat: update treatment and journey endpoints for patient authentication and dose registration * refactor(migrations): remove professional fields from treatments and dose_logs * Refactor( treatment)related tests and remove unnecessary professional references - Updated tests in `test_alert_after_checkin.py` and `test_dose_flow.py` to remove the need for health professional creation during treatment setup. - Simplified treatment creation logic in tests by removing the `prescribed_by` field. - Added new integration tests for patient journey in `test_journey_flow.py` to validate journey retrieval with doses and consultations. - Enhanced existing tests in `test_patient_health_appointment.py` and `test_patient_treatment_record.py` to align with the new treatment structure. - Introduced unit tests for `JourneyService` to validate journey calculations and event handling. - Updated treatment use case tests to ensure proper validation when creating treatments without active professionals. * feat(doses): Introduce v2 treatment endpoints and enhance dose registration logic - Updated treatment router to include v2 endpoints for treatments and journeys. - Added new treatment_v2 router with endpoints for creating treatments, getting treatment details, registering doses, and fetching adherence snapshots. - Enhanced DoseRepository and TreatmentRepository to handle duplicate dose and treatment registrations with appropriate error handling. - Introduced new schemas for v1 treatment and dose log responses to support the new API versioning. - Refactored use cases to separate v1 and v2 logic for treatment creation, adherence fetching, and dose registration. - Improved journey service to include skipped doses in the summary response. Co-authored-by: Rafael Luciano Co-authored-by: Sarah Domingos * fix(routes): implement v2 endpoints for dose registration and treatment creation, update journey URL * feat(treatments): add partial unique index for one active treatment per patient * tests(treatment): add E2E tests for patient treatment endpoints and enhance journey service tests * feat(treatments): add v2 endpoints for retrieving treatment and adherence snapshots * feat(treatments): enhance treatment and dose models with new fields and constraints * feat(tests): add E2E tests for legacy treatment and dose flow, including unique index conflict handling * feat(treatments): make professional treatment fields optional for patient-only v2 * test(treatments): fix appointment dose regression test * feat(journey): persist and unify treatment events --------- Co-authored-by: Rafael Luciano Co-authored-by: Sarah Domingos --- .../versions/109_patient_only_treatments.py | 46 +++ .../versions/110_unique_active_treatment.py | 32 ++ .../versions/111_create_journey_events.py | 64 ++++ backend/bruno/dose/register_dose.bru | 34 +- backend/bruno/dose/register_dose_v2.bru | 35 +++ backend/bruno/journey/get_journey.bru | 29 ++ backend/bruno/journey/get_patient_journey.bru | 34 ++ backend/bruno/treatment/create_treatment.bru | 12 +- .../bruno/treatment/create_treatment_v2.bru | 36 +++ backend/bruno/treatment/get_adherence_v2.bru | 37 +++ backend/bruno/treatment/get_treatment_v2.bru | 31 ++ backend/src/pequi/main.py | 13 + backend/src/pequi/models/__init__.py | 2 + backend/src/pequi/models/journey_event.py | 39 +++ backend/src/pequi/models/treatment.py | 21 +- .../src/pequi/repositories/account_repo.py | 9 + backend/src/pequi/repositories/dose_repo.py | 48 ++- .../pequi/repositories/journey_event_repo.py | 89 ++++++ .../src/pequi/repositories/treatment_repo.py | 39 ++- backend/src/pequi/routers/journey.py | 33 ++ backend/src/pequi/routers/patient.py | 26 +- backend/src/pequi/routers/treatment.py | 84 ++--- backend/src/pequi/routers/treatment_v2.py | 91 ++++++ backend/src/pequi/schemas/dose_log.py | 19 +- backend/src/pequi/schemas/journey.py | 71 +++++ backend/src/pequi/schemas/treatment.py | 4 +- backend/src/pequi/schemas/v1/__init__.py | 0 backend/src/pequi/schemas/v1/dose_log.py | 39 +++ backend/src/pequi/schemas/v1/treatment.py | 45 +++ .../appointment_consultation_effects.py | 17 +- backend/src/pequi/services/journey_service.py | 266 ++++++++++++++++ .../src/pequi/use_cases/create_treatment.py | 57 +--- .../pequi/use_cases/export_account_data.py | 22 +- backend/src/pequi/use_cases/get_adherence.py | 49 +-- .../pequi/use_cases/get_patient_journey.py | 49 +++ backend/src/pequi/use_cases/get_treatment.py | 52 +-- .../use_cases/patient_health_appointment.py | 14 +- .../use_cases/patient_treatment_record.py | 12 +- backend/src/pequi/use_cases/register_dose.py | 106 ++----- .../src/pequi/use_cases/treatment_schedule.py | 19 ++ backend/src/pequi/use_cases/v1/__init__.py | 0 .../pequi/use_cases/v1/create_treatment.py | 61 ++++ .../src/pequi/use_cases/v1/get_adherence.py | 49 +++ .../src/pequi/use_cases/v1/get_treatment.py | 74 +++++ .../src/pequi/use_cases/v1/register_dose.py | 127 ++++++++ backend/tests/e2e/test_journey_endpoint.py | 60 ++++ .../e2e/test_patient_treatment_endpoints.py | 105 +++++++ .../e2e/test_v1_treatment_compatibility.py | 131 ++++++++ .../integration/test_account_deletion.py | 2 - .../test_account_export_and_consents.py | 11 + .../integration/test_adherence_worker.py | 3 - .../integration/test_alert_after_checkin.py | 4 +- backend/tests/integration/test_dose_flow.py | 202 +++++------- .../tests/integration/test_journey_flow.py | 250 +++++++++++++++ .../test_patient_health_appointment.py | 127 +++++--- .../test_patient_treatment_record.py | 32 +- .../tests/integration/test_summary_worker.py | 1 - backend/tests/unit/test_journey_service.py | 297 ++++++++++++++++++ .../tests/unit/test_treatment_use_cases.py | 152 ++------- docs/milestones/M3-treatments-doses.md | 146 ++++----- 60 files changed, 2743 insertions(+), 816 deletions(-) create mode 100644 backend/alembic/versions/109_patient_only_treatments.py create mode 100644 backend/alembic/versions/110_unique_active_treatment.py create mode 100644 backend/alembic/versions/111_create_journey_events.py create mode 100644 backend/bruno/dose/register_dose_v2.bru create mode 100644 backend/bruno/journey/get_journey.bru create mode 100644 backend/bruno/journey/get_patient_journey.bru create mode 100644 backend/bruno/treatment/create_treatment_v2.bru create mode 100644 backend/bruno/treatment/get_adherence_v2.bru create mode 100644 backend/bruno/treatment/get_treatment_v2.bru create mode 100644 backend/src/pequi/models/journey_event.py create mode 100644 backend/src/pequi/repositories/journey_event_repo.py create mode 100644 backend/src/pequi/routers/journey.py create mode 100644 backend/src/pequi/routers/treatment_v2.py create mode 100644 backend/src/pequi/schemas/journey.py create mode 100644 backend/src/pequi/schemas/v1/__init__.py create mode 100644 backend/src/pequi/schemas/v1/dose_log.py create mode 100644 backend/src/pequi/schemas/v1/treatment.py create mode 100644 backend/src/pequi/services/journey_service.py create mode 100644 backend/src/pequi/use_cases/get_patient_journey.py create mode 100644 backend/src/pequi/use_cases/treatment_schedule.py create mode 100644 backend/src/pequi/use_cases/v1/__init__.py create mode 100644 backend/src/pequi/use_cases/v1/create_treatment.py create mode 100644 backend/src/pequi/use_cases/v1/get_adherence.py create mode 100644 backend/src/pequi/use_cases/v1/get_treatment.py create mode 100644 backend/src/pequi/use_cases/v1/register_dose.py create mode 100644 backend/tests/e2e/test_journey_endpoint.py create mode 100644 backend/tests/e2e/test_patient_treatment_endpoints.py create mode 100644 backend/tests/e2e/test_v1_treatment_compatibility.py create mode 100644 backend/tests/integration/test_journey_flow.py create mode 100644 backend/tests/unit/test_journey_service.py diff --git a/backend/alembic/versions/109_patient_only_treatments.py b/backend/alembic/versions/109_patient_only_treatments.py new file mode 100644 index 0000000..3998049 --- /dev/null +++ b/backend/alembic/versions/109_patient_only_treatments.py @@ -0,0 +1,46 @@ +"""make professional treatment fields optional for patient-only v2 + +Revision ID: 109_patient_only_treatments +Revises: 108_patient_health_appointments +Create Date: 2026-06-07 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "109_patient_only_treatments" +down_revision: str | None = "108_patient_health_appointments" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column("treatments", "prescribed_by", nullable=True) + + +def downgrade() -> None: + op.execute( + """ + UPDATE treatments t + SET prescribed_by = hp.id + FROM ( + SELECT id FROM health_professionals + WHERE deleted_at IS NULL + ORDER BY created_at + LIMIT 1 + ) hp + WHERE t.prescribed_by IS NULL + """ + ) + bind = op.get_bind() + null_count = bind.execute( + sa.text("SELECT COUNT(*) FROM treatments WHERE prescribed_by IS NULL") + ).scalar_one() + if null_count != 0: + raise RuntimeError( + "Cannot downgrade to required treatments.prescribed_by while treatments without " + "a prescriber exist and no health professional is available to backfill them." + ) + op.alter_column("treatments", "prescribed_by", nullable=False) diff --git a/backend/alembic/versions/110_unique_active_treatment.py b/backend/alembic/versions/110_unique_active_treatment.py new file mode 100644 index 0000000..5158fbe --- /dev/null +++ b/backend/alembic/versions/110_unique_active_treatment.py @@ -0,0 +1,32 @@ +"""partial unique index: one active treatment per patient + +Revision ID: 110_unique_active_treatment +Revises: 109_patient_only_treatments +Create Date: 2026-06-07 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "110_unique_active_treatment" +down_revision: str | None = "109_patient_only_treatments" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_index( + "uq_treatments_one_active_per_patient", + "treatments", + ["patient_id"], + unique=True, + postgresql_where="status = 'active' AND deleted_at IS NULL", + ) + + +def downgrade() -> None: + op.drop_index( + "uq_treatments_one_active_per_patient", + table_name="treatments", + ) diff --git a/backend/alembic/versions/111_create_journey_events.py b/backend/alembic/versions/111_create_journey_events.py new file mode 100644 index 0000000..3424715 --- /dev/null +++ b/backend/alembic/versions/111_create_journey_events.py @@ -0,0 +1,64 @@ +"""create persisted journey events + +Revision ID: 111_create_journey_events +Revises: 110_unique_active_treatment +Create Date: 2026-06-08 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision: str = "111_create_journey_events" +down_revision: str | None = "110_unique_active_treatment" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "journey_events", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "patient_id", + UUID(as_uuid=True), + sa.ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "treatment_id", + UUID(as_uuid=True), + sa.ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("event_type", sa.String(50), nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("metadata", JSONB(), server_default="{}", nullable=False), + sa.Column("source_type", sa.String(50), nullable=True), + sa.Column("source_id", UUID(as_uuid=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.UniqueConstraint("source_type", "source_id", name="uq_journey_events_source"), + ) + op.create_index( + "ix_journey_events_patient_occurred_at", + "journey_events", + ["patient_id", "occurred_at"], + ) + op.create_index("ix_journey_events_treatment_id", "journey_events", ["treatment_id"]) + op.create_index("ix_journey_events_event_type", "journey_events", ["event_type"]) + + +def downgrade() -> None: + op.drop_index("ix_journey_events_event_type", table_name="journey_events") + op.drop_index("ix_journey_events_treatment_id", table_name="journey_events") + op.drop_index("ix_journey_events_patient_occurred_at", table_name="journey_events") + op.drop_table("journey_events") diff --git a/backend/bruno/dose/register_dose.bru b/backend/bruno/dose/register_dose.bru index b3525a0..6957684 100644 --- a/backend/bruno/dose/register_dose.bru +++ b/backend/bruno/dose/register_dose.bru @@ -1,5 +1,5 @@ meta { - name: Register Dose + name: Register Dose (v1) type: http seq: 1 } @@ -24,7 +24,6 @@ body:json { "expected_at": "2026-02-15T08:00:00Z", "taken_at": "2026-02-15T08:30:00Z", "skipped": false, - "skip_reason": null, "supervised": false } } @@ -34,37 +33,10 @@ assert { res.body.id: isDefined res.body.treatment_id: eq "{{treatmentId}}" res.body.drug_name: eq "Dapsona" - res.body.skipped: eq false res.body.supervised: eq false - res.body.created_at: isDefined } docs { - Registra uma dose (tomada, pulada ou supervisionada) para o tratamento. - - Regras de negócio: - - Paciente: doses diárias com `supervised: false`. - - Paciente: dose supervisionada somente com `supervised: true` e `via_consultation: true` - (após registrar consulta realizada no app). - - Profissional: pode registrar doses supervisionadas (`supervised: true`) - com `registered_by` preenchido automaticamente. - - Dose duplicada (mesmo `drug_name` + `expected_at` + tratamento) retorna 409. - - Rate limit: 20/minuto. - - Exemplo de dose supervisionada (para profissional): - { - "drug_name": "Rifampicina", - "expected_at": "2026-02-01T09:00:00Z", - "taken_at": "2026-02-01T09:15:00Z", - "supervised": true - } - - Exemplo de dose pulada: - { - "drug_name": "Dapsona", - "expected_at": "2026-02-16T08:00:00Z", - "skipped": true, - "skip_reason": "Paciente relatou náusea intensa." - } + Contrato legado v1 — paciente ou profissional. + Preferir v2 para fluxo patient-first. } diff --git a/backend/bruno/dose/register_dose_v2.bru b/backend/bruno/dose/register_dose_v2.bru new file mode 100644 index 0000000..2b7f45c --- /dev/null +++ b/backend/bruno/dose/register_dose_v2.bru @@ -0,0 +1,35 @@ +meta { + name: Register Dose (v2 patient) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/v2/treatments/{{treatmentId}}/doses + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "drug_name": "Dapsona", + "expected_at": "2026-02-15T08:00:00Z", + "taken_at": "2026-02-15T08:30:00Z", + "skipped": false + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.treatment_id: eq "{{treatmentId}}" + res.body.drug_name: eq "Dapsona" +} diff --git a/backend/bruno/journey/get_journey.bru b/backend/bruno/journey/get_journey.bru new file mode 100644 index 0000000..1017d4a --- /dev/null +++ b/backend/bruno/journey/get_journey.bru @@ -0,0 +1,29 @@ +meta { + name: Get Journey v2 + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/v2/journey + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.patient.id: isDefined + res.body.treatment.id: isDefined + res.body.summary.total_months: isDefined + res.body.summary.total_consultations: isDefined + res.body.summary.total_doses_registered: isDefined + res.body.months[0].month_number: isDefined + res.body.months[0].is_current: isDefined +} + +docs { + Alias v2 da jornada do paciente autenticado. +} diff --git a/backend/bruno/journey/get_patient_journey.bru b/backend/bruno/journey/get_patient_journey.bru new file mode 100644 index 0000000..97e7fbd --- /dev/null +++ b/backend/bruno/journey/get_patient_journey.bru @@ -0,0 +1,34 @@ +meta { + name: Get Patient Journey + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/patients/me/journey + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.patient.id: isDefined + res.body.treatment.id: isDefined + res.body.summary.total_months: isDefined + res.body.summary.current_month: isDefined + res.body.summary.total_consultations: isDefined + res.body.summary.total_doses_registered: isDefined + res.body.months: isDefined + res.body.months[0].month_number: isDefined + res.body.months[0].is_current: isDefined +} + +docs { + Retorna a jornada do paciente autenticado com meses em ordem decrescente. + + Eventos persistidos suportam doses e futuras origens clinicas geradas por workers. + Rate limit: 100/minuto por paciente. +} diff --git a/backend/bruno/treatment/create_treatment.bru b/backend/bruno/treatment/create_treatment.bru index 24a5b1b..a7ce2e4 100644 --- a/backend/bruno/treatment/create_treatment.bru +++ b/backend/bruno/treatment/create_treatment.bru @@ -1,5 +1,5 @@ meta { - name: Create Treatment + name: Create Treatment (v1 professional) type: http seq: 1 } @@ -38,12 +38,6 @@ assert { } docs { - Cria um novo tratamento MDT para o paciente informado. - - Apenas profissionais de saúde autenticados podem chamar este endpoint. - O campo `expected_end` é calculado automaticamente: - - PB → start_date + 6 meses - - MB → start_date + 12 meses - - Rate limit: 10/minuto por profissional. + Contrato legado v1 — apenas profissionais autenticados. + Preferir v2 para fluxo patient-first. } diff --git a/backend/bruno/treatment/create_treatment_v2.bru b/backend/bruno/treatment/create_treatment_v2.bru new file mode 100644 index 0000000..249f7bb --- /dev/null +++ b/backend/bruno/treatment/create_treatment_v2.bru @@ -0,0 +1,36 @@ +meta { + name: Create Treatment (v2 patient) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/v2/treatments + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "regimen": "PB", + "start_date": "2026-01-15", + "notes": "Tratamento PB iniciado pelo paciente." + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.patient_id: isDefined + res.body.regimen: eq "PB" + res.body.expected_end: isDefined + res.body.status: eq "active" +} diff --git a/backend/bruno/treatment/get_adherence_v2.bru b/backend/bruno/treatment/get_adherence_v2.bru new file mode 100644 index 0000000..dd19828 --- /dev/null +++ b/backend/bruno/treatment/get_adherence_v2.bru @@ -0,0 +1,37 @@ +meta { + name: Get Adherence Snapshot (v2 patient) + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/v2/treatments/{{treatmentId}}/adherence + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: isDefined + res.body.treatment_id: eq "{{treatmentId}}" + res.body.patient_id: isDefined + res.body.period_start: isDefined + res.body.period_end: isDefined + res.body.total_doses: isDefined + res.body.taken_doses: isDefined + res.body.adherence_pct: isDefined + res.body.calculated_at: isDefined +} + +docs { + Retorna o snapshot de adesão mais recente para o tratamento v2. + + IMPORTANTE: A adesão NUNCA é calculada em tempo real. + Este endpoint lê exclusivamente de `adherence_snapshots`. + + Acessível apenas pelo paciente dono do tratamento. + Rate limit: 100/minuto. +} diff --git a/backend/bruno/treatment/get_treatment_v2.bru b/backend/bruno/treatment/get_treatment_v2.bru new file mode 100644 index 0000000..09079c6 --- /dev/null +++ b/backend/bruno/treatment/get_treatment_v2.bru @@ -0,0 +1,31 @@ +meta { + name: Get Treatment (v2 patient) + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v2/treatments/{{treatmentId}} + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: eq "{{treatmentId}}" + res.body.patient_id: isDefined + res.body.regimen: isDefined + res.body.status: isDefined + res.body.start_date: isDefined + res.body.expected_end: isDefined +} + +docs { + Retorna os dados de um tratamento v2 pelo ID. + + Acessível apenas pelo paciente dono do tratamento. + Rate limit: 100/minuto. +} diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 3bd510a..7f40ccc 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -77,13 +77,26 @@ async def health_check() -> JSONResponse: from pequi.routers import body_map as body_map_router from pequi.routers import checkin as checkin_router from pequi.routers import community as community_router + from pequi.routers import journey as journey_router from pequi.routers import patient as patient_router from pequi.routers import treatment as treatment_router + from pequi.routers import treatment_v2 as treatment_v2_router app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) + app.include_router( + journey_router.router, + prefix="/v1/patients/me/journey", + tags=["journey"], + ) + app.include_router(journey_router.router, prefix="/v2/journey", tags=["journey"]) app.include_router(account_router.router, prefix="/v1/account", tags=["account"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) app.include_router(treatment_router.router, prefix="/v1/treatments", tags=["treatments"]) + app.include_router( + treatment_v2_router.router, + prefix="/v2/treatments", + tags=["treatments-v2"], + ) app.include_router(treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"]) app.include_router(checkin_router.router, prefix="/v1/checkins", tags=["checkins"]) app.include_router(checkin_router.alerts_router, prefix="/v1/alerts", tags=["alerts"]) diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index f28561b..3818323 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -15,6 +15,7 @@ from pequi.models.health_appointment import PatientHealthAppointment from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit +from pequi.models.journey_event import JourneyEvent from pequi.models.patient import PatientProfile from pequi.models.symptom import Symptom from pequi.models.treatment import DoseSchedule, Treatment @@ -43,6 +44,7 @@ "HealthProfessional", "PatientHealthAppointment", "HealthUnit", + "JourneyEvent", "PatientProfile", "Symptom", "Treatment", diff --git a/backend/src/pequi/models/journey_event.py b/backend/src/pequi/models/journey_event.py new file mode 100644 index 0000000..269bb9d --- /dev/null +++ b/backend/src/pequi/models/journey_event.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy import Column, DateTime, ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class JourneyEvent(Base): + """Evento unificado e persistido da jornada do paciente.""" + + __tablename__ = "journey_events" + __table_args__ = ( + UniqueConstraint("source_type", "source_id", name="uq_journey_events_source"), + Index("ix_journey_events_patient_occurred_at", "patient_id", "occurred_at"), + Index("ix_journey_events_treatment_id", "treatment_id"), + Index("ix_journey_events_event_type", "event_type"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + treatment_id = Column( + UUID(as_uuid=True), + ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=True, + ) + event_type = Column(String(50), nullable=False) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=False) + occurred_at = Column(DateTime(timezone=True), nullable=False) + event_metadata = Column("metadata", JSONB, nullable=False, default=dict, server_default="{}") + source_type = Column(String(50), nullable=True) + source_id = Column(UUID(as_uuid=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/src/pequi/models/treatment.py b/backend/src/pequi/models/treatment.py index 5415d93..8928e1f 100644 --- a/backend/src/pequi/models/treatment.py +++ b/backend/src/pequi/models/treatment.py @@ -2,7 +2,18 @@ from decimal import Decimal from enum import StrEnum -from sqlalchemy import Column, Date, DateTime, Enum, ForeignKey, Index, Numeric, SmallInteger, Text +from sqlalchemy import ( + Column, + Date, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + SmallInteger, + Text, + text, +) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.sql import func @@ -45,6 +56,12 @@ class Treatment(Base): Index("ix_treatments_prescribed_by", "prescribed_by"), Index("ix_treatments_status", "status"), Index("ix_treatments_deleted_at", "deleted_at"), + Index( + "uq_treatments_one_active_per_patient", + "patient_id", + unique=True, + postgresql_where=text("status = 'active' AND deleted_at IS NULL"), + ), ) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -56,7 +73,7 @@ class Treatment(Base): prescribed_by = Column( UUID(as_uuid=True), ForeignKey("health_professionals.id", ondelete="RESTRICT"), - nullable=False, + nullable=True, ) regimen = Column( Enum(TreatmentRegimen, name="treatment_regimen_enum"), diff --git a/backend/src/pequi/repositories/account_repo.py b/backend/src/pequi/repositories/account_repo.py index e3a9586..0e64d43 100644 --- a/backend/src/pequi/repositories/account_repo.py +++ b/backend/src/pequi/repositories/account_repo.py @@ -12,6 +12,7 @@ from pequi.models.consent import Consent from pequi.models.data_deletion import DataDeletionRequest, DataDeletionStatus from pequi.models.dose_log import AdherenceSnapshot, DoseLog +from pequi.models.journey_event import JourneyEvent from pequi.models.patient import PatientProfile from pequi.models.treatment import Treatment, TreatmentStatus from pequi.models.user import User @@ -99,6 +100,14 @@ async def list_adherence_snapshots(self, patient_id: UUID) -> list[AdherenceSnap ) return list((await self._session.execute(stmt)).scalars().all()) + async def list_journey_events(self, patient_id: UUID) -> list[JourneyEvent]: + stmt = ( + select(JourneyEvent) + .where(JourneyEvent.patient_id == patient_id) + .order_by(JourneyEvent.occurred_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + async def list_weekly_symptom_summaries(self, patient_id: UUID) -> list[WeeklySymptomSummary]: stmt = ( select(WeeklySymptomSummary) diff --git a/backend/src/pequi/repositories/dose_repo.py b/backend/src/pequi/repositories/dose_repo.py index 43ff975..547575a 100644 --- a/backend/src/pequi/repositories/dose_repo.py +++ b/backend/src/pequi/repositories/dose_repo.py @@ -2,23 +2,42 @@ from uuid import UUID from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from pequi.core.exceptions import ConflictError from pequi.core.logging import get_logger from pequi.models.dose_log import DoseLog logger = get_logger(__name__) +_DOSE_DEDUP_CONSTRAINT = "uq_dose_logs_dedup" + class DoseRepository: def __init__(self, session: AsyncSession) -> None: self._session = session async def create(self, dose_log: DoseLog) -> DoseLog: - self._session.add(dose_log) - await self._session.flush() - await self._session.refresh(dose_log) - return dose_log + try: + async with self._session.begin_nested(): + self._session.add(dose_log) + await self._session.flush() + await self._session.refresh(dose_log) + return dose_log + except IntegrityError as exc: + if _constraint_violated(exc, _DOSE_DEDUP_CONSTRAINT): + logger.warning( + "duplicate_dose_attempt", + treatment_id=str(dose_log.treatment_id), + drug_name=dose_log.drug_name, + expected_at=dose_log.expected_at.isoformat(), + ) + raise ConflictError( + f"Dose duplicada: já existe registro para '{dose_log.drug_name}' " + f"em {dose_log.expected_at.isoformat()} neste tratamento." + ) from exc + raise async def exists_duplicate( self, @@ -33,15 +52,7 @@ async def exists_duplicate( DoseLog.expected_at == expected_at, ) result = await self._session.execute(stmt) - duplicate = result.scalar_one_or_none() is not None - if duplicate: - logger.warning( - "duplicate_dose_attempt", - treatment_id=str(treatment_id), - drug_name=drug_name, - expected_at=expected_at.isoformat(), - ) - return duplicate + return result.scalar_one_or_none() is not None async def list_by_treatment(self, treatment_id: UUID) -> list[DoseLog]: stmt = ( @@ -73,3 +84,14 @@ async def count_missed_doses_in_week(self, patient_id: UUID) -> int: ) result = await self._session.execute(stmt) return result.scalar_one() + + +def _constraint_violated(exc: IntegrityError, constraint_name: str) -> bool: + orig = getattr(exc, "orig", None) + if orig is None: + return constraint_name in str(exc) + diag = getattr(orig, "__cause__", None) or orig + pg_constraint = getattr(diag, "constraint_name", None) + if pg_constraint == constraint_name: + return True + return constraint_name in str(exc) diff --git a/backend/src/pequi/repositories/journey_event_repo.py b/backend/src/pequi/repositories/journey_event_repo.py new file mode 100644 index 0000000..4324013 --- /dev/null +++ b/backend/src/pequi/repositories/journey_event_repo.py @@ -0,0 +1,89 @@ +import uuid +from uuid import UUID + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.dose_log import DoseLog +from pequi.models.journey_event import JourneyEvent + + +class JourneyEventRepository: + """Persistência de eventos emitidos por fluxos clínicos e workers.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, event: JourneyEvent) -> JourneyEvent: + self._session.add(event) + await self._session.flush() + await self._session.refresh(event) + return event + + async def create_for_dose(self, patient_id: UUID, dose: DoseLog) -> JourneyEvent: + taken = dose.taken_at is not None and not dose.skipped + title = "Dose registrada" + if dose.skipped: + description = f"{dose.drug_name} marcada como não tomada." + display_type = "dose_skipped" + elif taken: + description = f"{dose.drug_name} registrada como tomada." + display_type = "dose_taken" + else: + description = f"{dose.drug_name} registrada como pendente." + display_type = "dose_pending" + + return await self.create( + JourneyEvent( + id=uuid.uuid4(), + patient_id=patient_id, + treatment_id=dose.treatment_id, + event_type="dose_registered", + title=title, + description=description, + occurred_at=dose.taken_at or dose.expected_at, + event_metadata={ + "drug_name": dose.drug_name, + "display_type": display_type, + "skipped": dose.skipped, + }, + source_type="dose_log", + source_id=dose.id, + ) + ) + + async def list_by_patient(self, patient_id: UUID) -> list[JourneyEvent]: + stmt = ( + select(JourneyEvent) + .where(JourneyEvent.patient_id == patient_id) + .order_by(JourneyEvent.occurred_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def list_for_treatment( + self, + patient_id: UUID, + treatment_id: UUID, + ) -> list[JourneyEvent]: + stmt = ( + select(JourneyEvent) + .where( + JourneyEvent.patient_id == patient_id, + or_( + JourneyEvent.treatment_id == treatment_id, + JourneyEvent.treatment_id.is_(None), + ), + ) + .order_by(JourneyEvent.occurred_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_source(self, source_type: str, source_id: UUID) -> JourneyEvent | None: + stmt = select(JourneyEvent).where( + JourneyEvent.source_type == source_type, + JourneyEvent.source_id == source_id, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/src/pequi/repositories/treatment_repo.py b/backend/src/pequi/repositories/treatment_repo.py index bcbedc5..b19b172 100644 --- a/backend/src/pequi/repositories/treatment_repo.py +++ b/backend/src/pequi/repositories/treatment_repo.py @@ -1,22 +1,32 @@ from uuid import UUID from sqlalchemy import desc, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from pequi.core.exceptions import ConflictError from pequi.models.dose_log import AdherenceSnapshot from pequi.models.symptom import Symptom from pequi.models.treatment import Treatment, TreatmentStatus +_ACTIVE_TREATMENT_CONSTRAINT = "uq_treatments_one_active_per_patient" + class TreatmentRepository: def __init__(self, session: AsyncSession) -> None: self._session = session async def create(self, treatment: Treatment) -> Treatment: - self._session.add(treatment) - await self._session.flush() - await self._session.refresh(treatment) - return treatment + try: + async with self._session.begin_nested(): + self._session.add(treatment) + await self._session.flush() + await self._session.refresh(treatment) + return treatment + except IntegrityError as exc: + if _constraint_violated(exc, _ACTIVE_TREATMENT_CONSTRAINT): + raise ConflictError("Paciente já possui um tratamento ativo.") from exc + raise async def get_by_id(self, treatment_id: UUID) -> Treatment | None: """Retorna tratamento ativo (não soft-deleted).""" @@ -42,9 +52,13 @@ async def list_by_patient_id( *, status: TreatmentStatus | None = None, ) -> list[Treatment]: - stmt = select(Treatment).where( - Treatment.patient_id == patient_id, - Treatment.deleted_at.is_(None), + stmt = ( + select(Treatment) + .where( + Treatment.patient_id == patient_id, + Treatment.deleted_at.is_(None), + ) + .order_by(desc(Treatment.created_at)) ) if status is not None: stmt = stmt.where(Treatment.status == status) @@ -78,3 +92,14 @@ async def get_by_ids(self, symptom_ids: list[UUID]) -> list[Symptom]: stmt = select(Symptom).where(Symptom.id.in_(symptom_ids)) result = await self._session.execute(stmt) return list(result.scalars().all()) + + +def _constraint_violated(exc: IntegrityError, constraint_name: str) -> bool: + orig = getattr(exc, "orig", None) + if orig is None: + return constraint_name in str(exc) + diag = getattr(orig, "__cause__", None) or orig + pg_constraint = getattr(diag, "constraint_name", None) + if pg_constraint == constraint_name: + return True + return constraint_name in str(exc) diff --git a/backend/src/pequi/routers/journey.py b/backend/src/pequi/routers/journey.py new file mode 100644 index 0000000..1d596f3 --- /dev/null +++ b/backend/src/pequi/routers/journey.py @@ -0,0 +1,33 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import get_current_patient, get_db +from pequi.core.rate_limit import limiter +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.journey import JourneyResponse +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase + +router = APIRouter() + + +@router.get("", response_model=JourneyResponse) +@limiter.limit("100/minute") +async def get_journey( + request: Request, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> JourneyResponse: + use_case = GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + DoseRepository(session), + HealthAppointmentRepository(session), + JourneyEventRepository(session), + ) + return await use_case.execute(patient_user_id) diff --git a/backend/src/pequi/routers/patient.py b/backend/src/pequi/routers/patient.py index d8afa0b..adc761d 100644 --- a/backend/src/pequi/routers/patient.py +++ b/backend/src/pequi/routers/patient.py @@ -7,7 +7,7 @@ from pequi.core.rate_limit import limiter from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.health_appointment import ( @@ -49,15 +49,10 @@ def _treatment_repos( session: AsyncSession, -) -> tuple[ - PatientRepository, - TreatmentRepository, - HealthProfessionalRepository, -]: +) -> tuple[PatientRepository, TreatmentRepository]: return ( PatientRepository(session), TreatmentRepository(session), - HealthProfessionalRepository(session), ) @@ -116,7 +111,7 @@ async def get_my_treatment_record( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> PatientTreatmentRecordRead: - patient_repo, _, _ = _treatment_repos(session) + patient_repo, _ = _treatment_repos(session) use_case = GetPatientTreatmentRecordUseCase(patient_repo) return await use_case.execute(user_id) @@ -129,11 +124,10 @@ async def save_my_treatment_record( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> PatientTreatmentRecordRead: - patient_repo, treatment_repo, professional_repo = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) use_case = SavePatientTreatmentRecordUseCase( patient_repo, treatment_repo, - professional_repo, ) return await use_case.execute(user_id, body) @@ -145,7 +139,7 @@ async def get_my_active_treatment( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> TreatmentResponse | None: - patient_repo, treatment_repo, _ = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) use_case = GetPatientActiveTreatmentUseCase(patient_repo, treatment_repo) return await use_case.execute(user_id) @@ -157,7 +151,7 @@ async def get_my_medication_checklist( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> MedicationChecklistResponse: - patient_repo, treatment_repo, _ = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) use_case = GetMedicationChecklistUseCase(patient_repo, treatment_repo) return await use_case.execute(user_id) @@ -187,15 +181,15 @@ async def create_my_appointment( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> HealthAppointmentResponse: - patient_repo, treatment_repo, professional_repo = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) appointment_repo = HealthAppointmentRepository(session) dose_repo = DoseRepository(session) use_case = CreatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + JourneyEventRepository(session), ) return await use_case.execute(user_id, body) @@ -212,14 +206,14 @@ async def update_my_appointment( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> HealthAppointmentResponse: - patient_repo, treatment_repo, professional_repo = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) appointment_repo = HealthAppointmentRepository(session) dose_repo = DoseRepository(session) use_case = UpdatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + JourneyEventRepository(session), ) return await use_case.execute(user_id, appointment_id, body) diff --git a/backend/src/pequi/routers/treatment.py b/backend/src/pequi/routers/treatment.py index 7b01fe7..3d4edb4 100644 --- a/backend/src/pequi/routers/treatment.py +++ b/backend/src/pequi/routers/treatment.py @@ -12,20 +12,21 @@ from pequi.core.rate_limit import limiter from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import SymptomRepository, TreatmentRepository -from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse -from pequi.schemas.treatment import ( - AdherenceSnapshotResponse, - SymptomResponse, - TreatmentCreate, - TreatmentResponse, +from pequi.schemas.treatment import SymptomResponse +from pequi.schemas.v1.dose_log import DoseLogCreateV1, DoseLogResponseV1 +from pequi.schemas.v1.treatment import ( + AdherenceSnapshotResponseV1, + TreatmentCreateV1, + TreatmentResponseV1, ) -from pequi.use_cases.create_treatment import CreateTreatmentUseCase -from pequi.use_cases.get_adherence import GetAdherenceUseCase -from pequi.use_cases.get_treatment import GetTreatmentUseCase from pequi.use_cases.list_symptoms import ListSymptomsUseCase -from pequi.use_cases.register_dose import RegisterDoseUseCase +from pequi.use_cases.v1.create_treatment import CreateTreatmentV1UseCase +from pequi.use_cases.v1.get_adherence import GetAdherenceV1UseCase +from pequi.use_cases.v1.get_treatment import GetTreatmentV1UseCase +from pequi.use_cases.v1.register_dose import RegisterDoseV1UseCase router = APIRouter() symptoms_router = APIRouter() @@ -49,91 +50,68 @@ def _make_repos( ) -# --------------------------------------------------------------------------- -# POST /v1/treatments — apenas profissionais -# --------------------------------------------------------------------------- - - -@router.post("", response_model=TreatmentResponse, status_code=201) +@router.post("", response_model=TreatmentResponseV1, status_code=201) @limiter.limit("10/minute") async def create_treatment( request: Request, - body: TreatmentCreate, + body: TreatmentCreateV1, professional_user_id: UUID = Depends(get_current_professional), session: AsyncSession = Depends(get_db), -) -> TreatmentResponse: +) -> TreatmentResponseV1: treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) - use_case = CreateTreatmentUseCase(treatment_repo, patient_repo, professional_repo) + use_case = CreateTreatmentV1UseCase(treatment_repo, patient_repo, professional_repo) return await use_case.execute(professional_user_id, body) -# --------------------------------------------------------------------------- -# GET /v1/treatments/{id} — paciente ou profissional -# --------------------------------------------------------------------------- - - -@router.get("/{treatment_id}", response_model=TreatmentResponse) +@router.get("/{treatment_id}", response_model=TreatmentResponseV1) @limiter.limit("100/minute") async def get_treatment( request: Request, treatment_id: UUID, actor: tuple[UUID, str] = Depends(get_actor_from_token), session: AsyncSession = Depends(get_db), -) -> TreatmentResponse: +) -> TreatmentResponseV1: actor_user_id, actor_role = actor - treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) - use_case = GetTreatmentUseCase(treatment_repo, patient_repo, professional_repo) + use_case = GetTreatmentV1UseCase(treatment_repo, patient_repo, professional_repo) return await use_case.execute(actor_user_id, actor_role, treatment_id) -# --------------------------------------------------------------------------- -# POST /v1/treatments/{id}/doses — paciente ou profissional -# --------------------------------------------------------------------------- - - -@router.post("/{treatment_id}/doses", response_model=DoseLogResponse, status_code=201) +@router.post("/{treatment_id}/doses", response_model=DoseLogResponseV1, status_code=201) @limiter.limit("20/minute") async def register_dose( request: Request, treatment_id: UUID, - body: DoseLogCreate, + body: DoseLogCreateV1, actor: tuple[UUID, str] = Depends(get_actor_from_token), session: AsyncSession = Depends(get_db), -) -> DoseLogResponse: +) -> DoseLogResponseV1: actor_user_id, actor_role = actor - treatment_repo, patient_repo, professional_repo, dose_repo, _ = _make_repos(session) - use_case = RegisterDoseUseCase(treatment_repo, dose_repo, patient_repo, professional_repo) + use_case = RegisterDoseV1UseCase( + treatment_repo, + dose_repo, + patient_repo, + professional_repo, + JourneyEventRepository(session), + ) return await use_case.execute(actor_user_id, actor_role, treatment_id, body) -# --------------------------------------------------------------------------- -# GET /v1/treatments/{id}/adherence — paciente ou profissional -# --------------------------------------------------------------------------- - - -@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponse) +@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponseV1) @limiter.limit("100/minute") async def get_adherence( request: Request, treatment_id: UUID, actor: tuple[UUID, str] = Depends(get_actor_from_token), session: AsyncSession = Depends(get_db), -) -> AdherenceSnapshotResponse: +) -> AdherenceSnapshotResponseV1: actor_user_id, actor_role = actor - treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) - use_case = GetAdherenceUseCase(treatment_repo, patient_repo, professional_repo) + use_case = GetAdherenceV1UseCase(treatment_repo, patient_repo, professional_repo) return await use_case.execute(actor_user_id, actor_role, treatment_id) -# --------------------------------------------------------------------------- -# GET /v1/symptoms — qualquer usuário autenticado -# Registrado em main.py como prefix="/v1/symptoms" -# --------------------------------------------------------------------------- - - @symptoms_router.get("", response_model=list[SymptomResponse]) @limiter.limit("50/minute") async def list_symptoms( diff --git a/backend/src/pequi/routers/treatment_v2.py b/backend/src/pequi/routers/treatment_v2.py new file mode 100644 index 0000000..ade9599 --- /dev/null +++ b/backend/src/pequi/routers/treatment_v2.py @@ -0,0 +1,91 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import get_current_patient, get_db +from pequi.core.rate_limit import limiter +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse +from pequi.schemas.treatment import ( + AdherenceSnapshotResponse, + TreatmentCreate, + TreatmentResponse, +) +from pequi.use_cases.create_treatment import CreateTreatmentUseCase +from pequi.use_cases.get_adherence import GetAdherenceUseCase +from pequi.use_cases.get_treatment import GetTreatmentUseCase +from pequi.use_cases.register_dose import RegisterDoseUseCase + +router = APIRouter() + + +def _make_repos( + session: AsyncSession, +) -> tuple[TreatmentRepository, PatientRepository, DoseRepository]: + return ( + TreatmentRepository(session), + PatientRepository(session), + DoseRepository(session), + ) + + +@router.post("", response_model=TreatmentResponse, status_code=201) +@limiter.limit("10/minute") +async def create_treatment( + request: Request, + body: TreatmentCreate, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> TreatmentResponse: + treatment_repo, patient_repo, _ = _make_repos(session) + use_case = CreateTreatmentUseCase(treatment_repo, patient_repo) + return await use_case.execute(patient_user_id, body) + + +@router.get("/{treatment_id}", response_model=TreatmentResponse) +@limiter.limit("100/minute") +async def get_treatment( + request: Request, + treatment_id: UUID, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> TreatmentResponse: + treatment_repo, patient_repo, _ = _make_repos(session) + use_case = GetTreatmentUseCase(treatment_repo, patient_repo) + return await use_case.execute(patient_user_id, treatment_id) + + +@router.post("/{treatment_id}/doses", response_model=DoseLogResponse, status_code=201) +@limiter.limit("20/minute") +async def register_dose( + request: Request, + treatment_id: UUID, + body: DoseLogCreate, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DoseLogResponse: + treatment_repo, patient_repo, dose_repo = _make_repos(session) + use_case = RegisterDoseUseCase( + treatment_repo, + dose_repo, + patient_repo, + JourneyEventRepository(session), + ) + return await use_case.execute(patient_user_id, treatment_id, body) + + +@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponse) +@limiter.limit("100/minute") +async def get_adherence( + request: Request, + treatment_id: UUID, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> AdherenceSnapshotResponse: + treatment_repo, patient_repo, _ = _make_repos(session) + use_case = GetAdherenceUseCase(treatment_repo, patient_repo) + return await use_case.execute(patient_user_id, treatment_id) diff --git a/backend/src/pequi/schemas/dose_log.py b/backend/src/pequi/schemas/dose_log.py index 68848f1..1858656 100644 --- a/backend/src/pequi/schemas/dose_log.py +++ b/backend/src/pequi/schemas/dose_log.py @@ -5,11 +5,7 @@ class DoseLogCreate(BaseModel): - """Payload para registrar uma dose (tomada, pulada ou supervisionada). - - Validações de permissão (paciente vs. profissional, supervisionada vs. diária) - são realizadas no use case, não aqui. - """ + """Payload para o paciente registrar uma dose (tomada ou pulada).""" model_config = ConfigDict(extra="forbid") @@ -18,22 +14,11 @@ class DoseLogCreate(BaseModel): taken_at: datetime | None = None skipped: bool = False skip_reason: str | None = Field(default=None, max_length=500) - supervised: bool = False - via_consultation: bool = Field( - default=False, - description=( - "Quando true, paciente pode registrar dose supervisionada " - "apenas após consulta na unidade (autodeclaração no app)." - ), - ) @model_validator(mode="after") def validate_skip_and_taken(self) -> "DoseLogCreate": if self.skipped and self.taken_at is not None: raise ValueError("Uma dose não pode ser simultaneamente tomada e pulada.") - if self.skipped is False and self.taken_at is None and not self.supervised: - # Permite dose "pendente" (nem tomada nem pulada) apenas se não for o caso base - pass return self @@ -45,8 +30,6 @@ class DoseLogResponse(BaseModel): taken_at: datetime | None = None skipped: bool skip_reason: str | None = None - supervised: bool - registered_by: UUID | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/journey.py b/backend/src/pequi/schemas/journey.py new file mode 100644 index 0000000..9691463 --- /dev/null +++ b/backend/src/pequi/schemas/journey.py @@ -0,0 +1,71 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class JourneyEventResponse(BaseModel): + """Evento unificado da timeline.""" + + type: str + event_type: str + date: datetime | date + occurred_at: datetime | date + title: str + description: str + metadata: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMonthResponse(BaseModel): + month: int + month_number: int + is_current: bool + events: list[JourneyEventResponse] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +class JourneySummaryBlock(BaseModel): + completed_doses: int + pending_doses: int + skipped_doses: int = 0 + adherence_pct: Decimal | None = None + total_months: int + current_month: int + progress_pct: Decimal + total_consultations: int + total_doses_registered: int + + model_config = ConfigDict(from_attributes=True) + + +class JourneyPatientBlock(BaseModel): + id: UUID + classification: str | None = None + + +class JourneyTreatmentBlock(BaseModel): + id: UUID + regimen: str + start_date: date + expected_end: date + status: str + + +class JourneyResponse(BaseModel): + patient: JourneyPatientBlock + treatment: JourneyTreatmentBlock + patient_id: UUID + regimen: str + start_date: date + expected_end: date + current_month: int + progress_pct: Decimal + months: list[JourneyMonthResponse] + summary: JourneySummaryBlock + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/treatment.py b/backend/src/pequi/schemas/treatment.py index c3d9233..52cc64e 100644 --- a/backend/src/pequi/schemas/treatment.py +++ b/backend/src/pequi/schemas/treatment.py @@ -6,7 +6,7 @@ class TreatmentCreate(BaseModel): - """Payload para criar um novo tratamento MDT. + """Payload para o paciente criar seu próprio tratamento MDT. ``expected_end`` é calculado automaticamente pelo use case: PB = start_date + 6 meses, MB = start_date + 12 meses. @@ -14,7 +14,6 @@ class TreatmentCreate(BaseModel): model_config = ConfigDict(extra="forbid") - patient_id: UUID regimen: str = Field( ..., pattern="^(PB|MB)$", @@ -27,7 +26,6 @@ class TreatmentCreate(BaseModel): class TreatmentResponse(BaseModel): id: UUID patient_id: UUID - prescribed_by: UUID regimen: str start_date: date expected_end: date diff --git a/backend/src/pequi/schemas/v1/__init__.py b/backend/src/pequi/schemas/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pequi/schemas/v1/dose_log.py b/backend/src/pequi/schemas/v1/dose_log.py new file mode 100644 index 0000000..d813bbe --- /dev/null +++ b/backend/src/pequi/schemas/v1/dose_log.py @@ -0,0 +1,39 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DoseLogCreateV1(BaseModel): + """Contrato legado v1 — paciente ou profissional registra dose.""" + + model_config = ConfigDict(extra="forbid") + + drug_name: str = Field(..., min_length=1, max_length=200) + expected_at: datetime + taken_at: datetime | None = None + skipped: bool = False + skip_reason: str | None = Field(default=None, max_length=500) + supervised: bool = False + via_consultation: bool = False + + @model_validator(mode="after") + def validate_skip_and_taken(self) -> "DoseLogCreateV1": + if self.skipped and self.taken_at is not None: + raise ValueError("Uma dose não pode ser simultaneamente tomada e pulada.") + return self + + +class DoseLogResponseV1(BaseModel): + id: UUID + treatment_id: UUID + drug_name: str + expected_at: datetime + taken_at: datetime | None = None + skipped: bool + skip_reason: str | None = None + supervised: bool + registered_by: UUID | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/v1/treatment.py b/backend/src/pequi/schemas/v1/treatment.py new file mode 100644 index 0000000..21a6bf5 --- /dev/null +++ b/backend/src/pequi/schemas/v1/treatment.py @@ -0,0 +1,45 @@ +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class TreatmentCreateV1(BaseModel): + """Contrato legado v1 — profissional cria tratamento para um paciente.""" + + model_config = ConfigDict(extra="forbid") + + patient_id: UUID + regimen: str = Field(..., pattern="^(PB|MB)$") + start_date: date + notes: str | None = Field(default=None, max_length=2000) + + +class TreatmentResponseV1(BaseModel): + id: UUID + patient_id: UUID + prescribed_by: UUID + regimen: str + start_date: date + expected_end: date + status: str + notes: str | None = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class AdherenceSnapshotResponseV1(BaseModel): + id: UUID + patient_id: UUID + treatment_id: UUID + period_start: date + period_end: date + total_doses: int + taken_doses: int + adherence_pct: Decimal + calculated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/services/appointment_consultation_effects.py b/backend/src/pequi/services/appointment_consultation_effects.py index 2e75da8..6f6aef0 100644 --- a/backend/src/pequi/services/appointment_consultation_effects.py +++ b/backend/src/pequi/services/appointment_consultation_effects.py @@ -1,11 +1,12 @@ -"""Efeitos colaterais ao concluir uma consulta (tratamento + doses supervisionadas).""" +"""Efeitos colaterais ao concluir uma consulta (tratamento + doses).""" from datetime import UTC, date, datetime, time from uuid import UUID +from pequi.core.exceptions import ConflictError from pequi.models.treatment import TreatmentStatus from pequi.repositories.dose_repo import DoseRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate @@ -24,21 +25,20 @@ def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo self._save_treatment = SavePatientTreatmentRecordUseCase( patient_repo, treatment_repo, - professional_repo, ) self._register_dose = RegisterDoseUseCase( treatment_repo, dose_repo, patient_repo, - professional_repo, + journey_event_repo, ) async def apply_on_first_completion( @@ -122,17 +122,14 @@ async def _register_supervised_doses( for drug_name in drug_names: try: await self._register_dose.execute( - actor_user_id=user_id, - actor_role="patient", + patient_user_id=user_id, treatment_id=treatment.id, data=DoseLogCreate( drug_name=drug_name, expected_at=expected_at, taken_at=taken_at, skipped=False, - supervised=True, - via_consultation=True, ), ) - except Exception: + except ConflictError: continue diff --git a/backend/src/pequi/services/journey_service.py b/backend/src/pequi/services/journey_service.py new file mode 100644 index 0000000..c29a851 --- /dev/null +++ b/backend/src/pequi/services/journey_service.py @@ -0,0 +1,266 @@ +"""Montagem da jornada de tratamento do paciente.""" + +from __future__ import annotations + +import calendar +from datetime import UTC, date, datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal +from typing import TYPE_CHECKING + +from pequi.models.treatment import TreatmentRegimen +from pequi.schemas.journey import ( + JourneyEventResponse, + JourneyMonthResponse, + JourneyResponse, + JourneySummaryBlock, +) + +if TYPE_CHECKING: + from pequi.models.dose_log import AdherenceSnapshot, DoseLog + from pequi.models.health_appointment import PatientHealthAppointment + from pequi.models.journey_event import JourneyEvent + from pequi.models.patient import PatientProfile + from pequi.models.treatment import Treatment + +_REGIMEN_MONTHS = { + TreatmentRegimen.PB: 6, + TreatmentRegimen.MB: 12, +} + + +class JourneyService: + """Cálculo stateless de progresso, agrupamento mensal e timeline.""" + + @classmethod + def build_journey( + cls, + *, + patient_id, + treatment: Treatment, + doses: list[DoseLog], + appointments: list[PatientHealthAppointment], + adherence_snapshot: AdherenceSnapshot | None, + patient: PatientProfile | None = None, + journey_events: list[JourneyEvent] | None = None, + today: date | None = None, + ) -> JourneyResponse: + today = today or datetime.now(UTC).date() + regimen = ( + treatment.regimen.value + if hasattr(treatment.regimen, "value") + else str(treatment.regimen) + ) + total_months = _REGIMEN_MONTHS.get(TreatmentRegimen(regimen), 6) + current_month = cls.calculate_current_month(treatment.start_date, today, total_months) + progress_pct = cls.calculate_progress_pct( + treatment.start_date, + treatment.expected_end, + today, + ) + months = cls.build_months( + start_date=treatment.start_date, + total_months=total_months, + current_month=current_month, + doses=doses, + appointments=appointments, + journey_events=journey_events or [], + ) + summary = cls.build_summary( + doses, + appointments, + adherence_snapshot, + total_months=total_months, + current_month=current_month, + progress_pct=progress_pct, + ) + status = ( + treatment.status.value if hasattr(treatment.status, "value") else str(treatment.status) + ) + + return JourneyResponse( + patient={ + "id": patient_id, + "classification": getattr(patient, "classification", None), + }, + treatment={ + "id": treatment.id, + "regimen": regimen, + "start_date": treatment.start_date, + "expected_end": treatment.expected_end, + "status": status, + }, + patient_id=patient_id, + regimen=regimen, + start_date=treatment.start_date, + expected_end=treatment.expected_end, + current_month=current_month, + progress_pct=progress_pct, + months=months, + summary=summary, + ) + + @staticmethod + def calculate_current_month(start_date: date, today: date, total_months: int) -> int: + if today < start_date: + return 1 + months_elapsed = (today.year - start_date.year) * 12 + (today.month - start_date.month) + if today.day < start_date.day: + months_elapsed -= 1 + return min(total_months, max(1, months_elapsed + 1)) + + @staticmethod + def calculate_progress_pct(start_date: date, expected_end: date, today: date) -> Decimal: + total_days = (expected_end - start_date).days + if total_days <= 0: + return Decimal("0.0") + elapsed = max(0, min((today - start_date).days, total_days)) + pct = Decimal(elapsed) / Decimal(total_days) * Decimal("100") + return pct.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + + @classmethod + def build_months( + cls, + *, + start_date: date, + total_months: int, + current_month: int, + doses: list[DoseLog], + appointments: list[PatientHealthAppointment], + journey_events: list[JourneyEvent], + ) -> list[JourneyMonthResponse]: + persisted_dose_ids = { + event.source_id for event in journey_events if event.source_type == "dose_log" + } + months: list[JourneyMonthResponse] = [] + for month_index in range(1, total_months + 1): + month_start = _add_months(start_date, month_index - 1) + month_end = _add_months(start_date, month_index) - timedelta(days=1) + month_doses = [ + dose + for dose in doses + if dose.id not in persisted_dose_ids + and month_start <= dose.expected_at.date() <= month_end + ] + month_appointments = [ + item + for item in appointments + if item.performed and month_start <= item.appointment_date <= month_end + ] + persisted = [ + event + for event in journey_events + if month_start <= event.occurred_at.date() <= month_end + ] + events = cls._build_dose_events(month_doses) + events.extend(cls._build_consultation_events(month_appointments)) + events.extend(cls._build_persisted_events(persisted)) + events.sort(key=_event_sort_key) + months.append( + JourneyMonthResponse( + month=month_index, + month_number=month_index, + is_current=month_index == current_month, + events=events, + ) + ) + return list(reversed(months)) + + @staticmethod + def build_summary( + doses: list[DoseLog], + appointments: list[PatientHealthAppointment], + adherence_snapshot: AdherenceSnapshot | None, + *, + total_months: int, + current_month: int, + progress_pct: Decimal, + ) -> JourneySummaryBlock: + completed = sum(1 for dose in doses if dose.taken_at is not None and not dose.skipped) + pending = sum(1 for dose in doses if dose.taken_at is None and not dose.skipped) + skipped = sum(1 for dose in doses if dose.skipped) + adherence_pct = ( + Decimal(str(adherence_snapshot.adherence_pct)) + if adherence_snapshot is not None + else None + ) + return JourneySummaryBlock( + completed_doses=completed, + pending_doses=pending, + skipped_doses=skipped, + adherence_pct=adherence_pct, + total_months=total_months, + current_month=current_month, + progress_pct=progress_pct, + total_consultations=sum(1 for item in appointments if item.performed), + total_doses_registered=len(doses), + ) + + @staticmethod + def _build_dose_events(doses: list[DoseLog]) -> list[JourneyEventResponse]: + events: list[JourneyEventResponse] = [] + for dose in doses: + occurred_at = dose.taken_at or dose.expected_at + display_type = "dose_skipped" if dose.skipped else "dose_taken" + events.append( + JourneyEventResponse( + type=display_type, + event_type="dose_registered", + date=occurred_at, + occurred_at=occurred_at, + title="Dose pulada" if dose.skipped else "Dose tomada", + description=f"{dose.drug_name} registrada na jornada.", + metadata={"drug_name": dose.drug_name, "skipped": dose.skipped}, + ) + ) + return events + + @staticmethod + def _build_consultation_events( + appointments: list[PatientHealthAppointment], + ) -> list[JourneyEventResponse]: + events: list[JourneyEventResponse] = [] + for appointment in appointments: + description = f"{appointment.appointment_type} em {appointment.location}" + if appointment.professional: + description += f" com {appointment.professional}" + events.append( + JourneyEventResponse( + type="consultation_registered", + event_type="consultation", + date=appointment.appointment_date, + occurred_at=appointment.appointment_date, + title="Consulta registrada", + description=description, + ) + ) + return events + + @staticmethod + def _build_persisted_events(events: list[JourneyEvent]) -> list[JourneyEventResponse]: + return [ + JourneyEventResponse( + type=event.event_metadata.get("display_type", event.event_type), + event_type=event.event_type, + date=event.occurred_at, + occurred_at=event.occurred_at, + title=event.title, + description=event.description, + metadata=event.event_metadata, + ) + for event in events + ] + + +def _event_sort_key(event: JourneyEventResponse) -> datetime: + value = event.occurred_at + if isinstance(value, datetime): + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return datetime.combine(value, datetime.min.time(), tzinfo=UTC) + + +def _add_months(start_date: date, months: int) -> date: + total_months = start_date.month - 1 + months + year = start_date.year + total_months // 12 + month = total_months % 12 + 1 + day = min(start_date.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) diff --git a/backend/src/pequi/use_cases/create_treatment.py b/backend/src/pequi/use_cases/create_treatment.py index 04b135c..2acf573 100644 --- a/backend/src/pequi/use_cases/create_treatment.py +++ b/backend/src/pequi/use_cases/create_treatment.py @@ -1,77 +1,52 @@ -import calendar import uuid -from datetime import date -from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.core.exceptions import ConflictError, NotFoundError, ValidationFailedError from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.treatment import TreatmentCreate, TreatmentResponse - -_REGIMEN_MONTHS = { - TreatmentRegimen.PB: 6, - TreatmentRegimen.MB: 12, -} +from pequi.use_cases.treatment_schedule import calculate_expected_end class CreateTreatmentUseCase: - """Cria um tratamento MDT para um paciente. - - Apenas profissionais de saúde podem criar tratamentos, e somente para - pacientes da mesma unidade de saúde. - """ + """v2 — paciente autenticado cria seu próprio tratamento MDT.""" def __init__( self, treatment_repo: TreatmentRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._treatment_repo = treatment_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo async def execute( self, - professional_user_id: uuid.UUID, + patient_user_id: uuid.UUID, data: TreatmentCreate, ) -> TreatmentResponse: - professional = await self._professional_repo.get_by_user_id(professional_user_id) - if professional is None: - raise NotFoundError("HealthProfessional", str(professional_user_id)) - - patient = await self._patient_repo.get_by_id(data.patient_id) + patient = await self._patient_repo.get_by_user_id(patient_user_id) if patient is None: - raise NotFoundError("PatientProfile", str(data.patient_id)) + raise NotFoundError("PatientProfile", str(patient_user_id)) - if patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a pacientes de outra unidade de saúde." - ) + existing = await self._treatment_repo.get_active_by_patient_id(patient.id) + if existing is not None: + raise ValidationFailedError("Paciente já possui um tratamento ativo.") regimen = TreatmentRegimen(data.regimen) - expected_end = _calculate_expected_end(data.start_date, regimen) + expected_end = calculate_expected_end(data.start_date, regimen) treatment = Treatment( id=uuid.uuid4(), - patient_id=data.patient_id, - prescribed_by=professional.id, + patient_id=patient.id, regimen=regimen, start_date=data.start_date, expected_end=expected_end, status=TreatmentStatus.active, notes=data.notes, ) - treatment = await self._treatment_repo.create(treatment) - return TreatmentResponse.model_validate(treatment) - + try: + treatment = await self._treatment_repo.create(treatment) + except ConflictError: + raise ConflictError("Paciente já possui um tratamento ativo.") from None -def _calculate_expected_end(start_date: date, regimen: TreatmentRegimen) -> date: - """Adiciona N meses à data de início, limitando ao último dia do mês destino.""" - months = _REGIMEN_MONTHS[regimen] - total_months = start_date.month - 1 + months - year = start_date.year + total_months // 12 - month = total_months % 12 + 1 - day = min(start_date.day, calendar.monthrange(year, month)[1]) - return date(year, month, day) + return TreatmentResponse.model_validate(treatment) diff --git a/backend/src/pequi/use_cases/export_account_data.py b/backend/src/pequi/use_cases/export_account_data.py index 785d44c..12956ac 100644 --- a/backend/src/pequi/use_cases/export_account_data.py +++ b/backend/src/pequi/use_cases/export_account_data.py @@ -96,7 +96,6 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict include=[ "id", "patient_id", - "prescribed_by", "regimen", "start_date", "expected_end", @@ -119,8 +118,6 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict "taken_at", "skipped", "skip_reason", - "supervised", - "registered_by", "created_at", ], ) @@ -143,6 +140,25 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict ) for row in await self._repo.list_adherence_snapshots(patient.id) ], + "journey_events": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "treatment_id", + "event_type", + "title", + "description", + "occurred_at", + "source_type", + "source_id", + "created_at", + ], + extra={"metadata": row.event_metadata}, + ) + for row in await self._repo.list_journey_events(patient.id) + ], "alerts": [ self._dump( row, diff --git a/backend/src/pequi/use_cases/get_adherence.py b/backend/src/pequi/use_cases/get_adherence.py index 9324414..8f82b41 100644 --- a/backend/src/pequi/use_cases/get_adherence.py +++ b/backend/src/pequi/use_cases/get_adherence.py @@ -1,14 +1,13 @@ import uuid from pequi.core.exceptions import ForbiddenError, NotFoundError -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.treatment import AdherenceSnapshotResponse class GetAdherenceUseCase: - """Retorna o snapshot de adesão mais recente para um tratamento. + """Retorna o snapshot de adesão mais recente — somente o paciente dono. Nunca recalcula — lê exclusivamente de ``adherence_snapshots``. Retorna NotFoundError se nenhum snapshot foi calculado ainda pelo worker. @@ -18,29 +17,22 @@ def __init__( self, treatment_repo: TreatmentRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._treatment_repo = treatment_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo async def execute( self, - actor_user_id: uuid.UUID, - actor_role: str, + patient_user_id: uuid.UUID, treatment_id: uuid.UUID, ) -> AdherenceSnapshotResponse: treatment = await self._treatment_repo.get_by_id(treatment_id) if treatment is None: raise NotFoundError("Treatment", str(treatment_id)) - await _assert_access( - actor_user_id=actor_user_id, - actor_role=actor_role, - treatment=treatment, - patient_repo=self._patient_repo, - professional_repo=self._professional_repo, - ) + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment_id) if snapshot is None: @@ -50,34 +42,3 @@ async def execute( ) return AdherenceSnapshotResponse.model_validate(snapshot) - - -async def _assert_access( - *, - actor_user_id: uuid.UUID, - actor_role: str, - treatment, - patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, -) -> None: - if actor_role == "patient": - patient = await patient_repo.get_by_user_id(actor_user_id) - if patient is None or patient.id != treatment.patient_id: - raise ForbiddenError("Paciente não tem acesso a este tratamento.") - - elif actor_role == "health_professional": - professional = await professional_repo.get_by_user_id(actor_user_id) - if professional is None: - raise ForbiddenError("Perfil de profissional não encontrado.") - - patient = await patient_repo.get_by_id(treatment.patient_id) - if patient is None: - raise NotFoundError("PatientProfile", str(treatment.patient_id)) - - if patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a tratamentos de pacientes de outra unidade." - ) - - else: - raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/get_patient_journey.py b/backend/src/pequi/use_cases/get_patient_journey.py new file mode 100644 index 0000000..47fb475 --- /dev/null +++ b/backend/src/pequi/use_cases/get_patient_journey.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.journey import JourneyResponse +from pequi.services.journey_service import JourneyService + + +class GetPatientJourneyUseCase: + """Retorna a jornada de tratamento do paciente autenticado.""" + + def __init__( + self, + patient_repo: PatientRepository, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + appointment_repo: HealthAppointmentRepository, + journey_event_repo: JourneyEventRepository, + ) -> None: + self._patient_repo = patient_repo + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._appointment_repo = appointment_repo + self._journey_event_repo = journey_event_repo + + async def execute(self, user_id: UUID) -> JourneyResponse: + patient = await self._patient_repo.get_or_create_by_user_id(user_id) + treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) + if treatment is None: + raise NotFoundError("Treatment", "Nenhum tratamento ativo encontrado.") + + doses = await self._dose_repo.list_by_treatment(treatment.id) + appointments = await self._appointment_repo.list_by_patient_id(patient.id) + snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) + journey_events = await self._journey_event_repo.list_for_treatment(patient.id, treatment.id) + + return JourneyService.build_journey( + patient_id=patient.id, + patient=patient, + treatment=treatment, + doses=doses, + appointments=appointments, + journey_events=journey_events, + adherence_snapshot=snapshot, + ) diff --git a/backend/src/pequi/use_cases/get_treatment.py b/backend/src/pequi/use_cases/get_treatment.py index 2019491..448388b 100644 --- a/backend/src/pequi/use_cases/get_treatment.py +++ b/backend/src/pequi/use_cases/get_treatment.py @@ -1,77 +1,33 @@ import uuid from pequi.core.exceptions import ForbiddenError, NotFoundError -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.treatment import TreatmentResponse class GetTreatmentUseCase: - """Retorna um tratamento verificando permissões de acesso. - - - Paciente: só acessa o próprio tratamento. - - Profissional: só acessa tratamentos de pacientes da mesma unidade. - """ + """Retorna um tratamento — somente o paciente dono pode acessá-lo.""" def __init__( self, treatment_repo: TreatmentRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._treatment_repo = treatment_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo async def execute( self, - actor_user_id: uuid.UUID, - actor_role: str, + patient_user_id: uuid.UUID, treatment_id: uuid.UUID, ) -> TreatmentResponse: treatment = await self._treatment_repo.get_by_id(treatment_id) if treatment is None: raise NotFoundError("Treatment", str(treatment_id)) - await _assert_access( - actor_user_id=actor_user_id, - actor_role=actor_role, - treatment=treatment, - patient_repo=self._patient_repo, - professional_repo=self._professional_repo, - ) - - return TreatmentResponse.model_validate(treatment) - - -async def _assert_access( - *, - actor_user_id: uuid.UUID, - actor_role: str, - treatment, - patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, -) -> None: - """Verifica se o ator tem permissão para acessar o tratamento.""" - if actor_role == "patient": - patient = await patient_repo.get_by_user_id(actor_user_id) + patient = await self._patient_repo.get_by_user_id(patient_user_id) if patient is None or patient.id != treatment.patient_id: raise ForbiddenError("Paciente não tem acesso a este tratamento.") - elif actor_role == "health_professional": - professional = await professional_repo.get_by_user_id(actor_user_id) - if professional is None: - raise ForbiddenError("Perfil de profissional não encontrado.") - - patient = await patient_repo.get_by_id(treatment.patient_id) - if patient is None: - raise NotFoundError("PatientProfile", str(treatment.patient_id)) - - if patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a tratamentos de pacientes de outra unidade." - ) - - else: - raise ForbiddenError("Acesso negado.") + return TreatmentResponse.model_validate(treatment) diff --git a/backend/src/pequi/use_cases/patient_health_appointment.py b/backend/src/pequi/use_cases/patient_health_appointment.py index e4c085a..7bd3322 100644 --- a/backend/src/pequi/use_cases/patient_health_appointment.py +++ b/backend/src/pequi/use_cases/patient_health_appointment.py @@ -5,7 +5,7 @@ from pequi.models.health_appointment import PatientHealthAppointment from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.health_appointment import ( @@ -35,14 +35,14 @@ async def execute(self, user_id: UUID) -> list[HealthAppointmentResponse]: def _consultation_effects( patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> AppointmentConsultationEffects: return AppointmentConsultationEffects( patient_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ) @@ -73,16 +73,16 @@ def __init__( patient_repo: PatientRepository, appointment_repo: HealthAppointmentRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._patient_repo = patient_repo self._appointment_repo = appointment_repo self._effects = _consultation_effects( patient_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ) async def execute( @@ -115,16 +115,16 @@ def __init__( patient_repo: PatientRepository, appointment_repo: HealthAppointmentRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._patient_repo = patient_repo self._appointment_repo = appointment_repo self._effects = _consultation_effects( patient_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ) async def execute( diff --git a/backend/src/pequi/use_cases/patient_treatment_record.py b/backend/src/pequi/use_cases/patient_treatment_record.py index 03479d6..819a9b8 100644 --- a/backend/src/pequi/use_cases/patient_treatment_record.py +++ b/backend/src/pequi/use_cases/patient_treatment_record.py @@ -3,7 +3,6 @@ from pequi.core.exceptions import ValidationFailedError from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.patient_treatment import ( @@ -14,7 +13,7 @@ treatment_record_to_storage, ) from pequi.schemas.treatment import TreatmentResponse -from pequi.use_cases.create_treatment import _calculate_expected_end +from pequi.use_cases.treatment_schedule import calculate_expected_end class GetPatientTreatmentRecordUseCase: @@ -36,11 +35,9 @@ def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo - self._professional_repo = professional_repo async def execute( self, @@ -82,17 +79,12 @@ async def _ensure_active_mdt(self, patient_id: UUID, data: PatientTreatmentRecor if existing is not None: return - professional = await self._professional_repo.get_first_available() - if professional is None: - return - regimen = TreatmentRegimen(data.classification) - expected_end = _calculate_expected_end(data.treatment_start_date, regimen) + expected_end = calculate_expected_end(data.treatment_start_date, regimen) treatment = Treatment( id=uuid.uuid4(), patient_id=patient_id, - prescribed_by=professional.id, regimen=regimen, start_date=data.treatment_start_date, expected_end=expected_end, diff --git a/backend/src/pequi/use_cases/register_dose.py b/backend/src/pequi/use_cases/register_dose.py index 75d1a56..3346110 100644 --- a/backend/src/pequi/use_cases/register_dose.py +++ b/backend/src/pequi/use_cases/register_dose.py @@ -9,38 +9,30 @@ from pequi.models.dose_log import DoseLog from pequi.models.treatment import TreatmentStatus from pequi.repositories.dose_repo import DoseRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse class RegisterDoseUseCase: - """Registra uma dose (tomada, pulada ou supervisionada). - - Regras de negócio: - - Paciente só pode autoregistrar doses não supervisionadas do próprio tratamento ativo. - - Dose supervisionada deve ser registrada por profissional (registered_by != null). - - Profissional de outra unidade não pode registrar doses no tratamento. - - Duplicidade (treatment_id + drug_name + expected_at) retorna ConflictError → HTTP 409. - """ + """v2 — paciente registra qualquer dose do próprio tratamento ativo.""" def __init__( self, treatment_repo: TreatmentRepository, dose_repo: DoseRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._treatment_repo = treatment_repo self._dose_repo = dose_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo + self._journey_event_repo = journey_event_repo async def execute( self, - actor_user_id: uuid.UUID, - actor_role: str, + patient_user_id: uuid.UUID, treatment_id: uuid.UUID, data: DoseLogCreate, ) -> DoseLogResponse: @@ -48,32 +40,13 @@ async def execute( if treatment is None: raise NotFoundError("Treatment", str(treatment_id)) - registered_by: uuid.UUID | None = None - - if actor_role == "patient": - registered_by = await self._validate_patient_access( - actor_user_id=actor_user_id, - treatment=treatment, - data=data, - ) - elif actor_role == "health_professional": - registered_by = await self._validate_professional_access( - actor_user_id=actor_user_id, - treatment=treatment, - data=data, - ) - else: - raise ForbiddenError("Acesso negado.") + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") - duplicate = await self._dose_repo.exists_duplicate( - treatment_id=treatment_id, - drug_name=data.drug_name, - expected_at=data.expected_at, - ) - if duplicate: - raise ConflictError( - f"Dose duplicada: já existe registro para '{data.drug_name}' " - f"em {data.expected_at.isoformat()} neste tratamento." + if treatment.status != TreatmentStatus.active: + raise ValidationFailedError( + "Registro de dose permitido apenas em tratamentos com status 'active'." ) dose_log = DoseLog( @@ -84,53 +57,14 @@ async def execute( taken_at=data.taken_at, skipped=data.skipped, skip_reason=data.skip_reason, - supervised=data.supervised, - registered_by=registered_by, ) - dose_log = await self._dose_repo.create(dose_log) - return DoseLogResponse.model_validate(dose_log) - - async def _validate_patient_access( - self, - actor_user_id: uuid.UUID, - treatment, - data: DoseLogCreate, - ) -> None: - if data.supervised and not data.via_consultation: - raise ForbiddenError( - "Dose supervisionada só pode ser registrada ao informar uma consulta realizada." - ) - - patient = await self._patient_repo.get_by_user_id(actor_user_id) - if patient is None or patient.id != treatment.patient_id: - raise ForbiddenError("Paciente não tem acesso a este tratamento.") - - if treatment.status != TreatmentStatus.active: - raise ValidationFailedError( - "Autoregistro permitido apenas em tratamentos com status 'active'." - ) - - return None - - async def _validate_professional_access( - self, - actor_user_id: uuid.UUID, - treatment, - data: DoseLogCreate, - ) -> uuid.UUID: - professional = await self._professional_repo.get_by_user_id(actor_user_id) - if professional is None: - raise ForbiddenError("Perfil de profissional não encontrado.") - - patient = await self._patient_repo.get_by_id(treatment.patient_id) - if patient is None or patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a tratamentos de pacientes de outra unidade." - ) - - if data.supervised and professional is None: - raise ValidationFailedError( - "Dose supervisionada deve ser registrada por um profissional." - ) + try: + dose_log = await self._dose_repo.create(dose_log) + except ConflictError: + raise ConflictError( + f"Dose duplicada: já existe registro para '{data.drug_name}' " + f"em {data.expected_at.isoformat()} neste tratamento." + ) from None + await self._journey_event_repo.create_for_dose(patient.id, dose_log) - return professional.user_id + return DoseLogResponse.model_validate(dose_log) diff --git a/backend/src/pequi/use_cases/treatment_schedule.py b/backend/src/pequi/use_cases/treatment_schedule.py new file mode 100644 index 0000000..7ab319a --- /dev/null +++ b/backend/src/pequi/use_cases/treatment_schedule.py @@ -0,0 +1,19 @@ +import calendar +from datetime import date + +from pequi.models.treatment import TreatmentRegimen + +_REGIMEN_MONTHS = { + TreatmentRegimen.PB: 6, + TreatmentRegimen.MB: 12, +} + + +def calculate_expected_end(start_date: date, regimen: TreatmentRegimen) -> date: + """Adiciona N meses à data de início, limitando ao último dia do mês destino.""" + months = _REGIMEN_MONTHS[regimen] + total_months = start_date.month - 1 + months + year = start_date.year + total_months // 12 + month = total_months % 12 + 1 + day = min(start_date.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) diff --git a/backend/src/pequi/use_cases/v1/__init__.py b/backend/src/pequi/use_cases/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pequi/use_cases/v1/create_treatment.py b/backend/src/pequi/use_cases/v1/create_treatment.py new file mode 100644 index 0000000..fe0f6f8 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/create_treatment.py @@ -0,0 +1,61 @@ +import uuid + +from pequi.core.exceptions import ConflictError, ForbiddenError, NotFoundError +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.treatment import TreatmentCreateV1, TreatmentResponseV1 +from pequi.use_cases.treatment_schedule import calculate_expected_end + + +class CreateTreatmentV1UseCase: + """v1 — profissional cria tratamento para paciente da mesma unidade.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + professional_user_id: uuid.UUID, + data: TreatmentCreateV1, + ) -> TreatmentResponseV1: + professional = await self._professional_repo.get_by_user_id(professional_user_id) + if professional is None: + raise NotFoundError("HealthProfessional", str(professional_user_id)) + + patient = await self._patient_repo.get_by_id(data.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(data.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a pacientes de outra unidade de saúde." + ) + + regimen = TreatmentRegimen(data.regimen) + expected_end = calculate_expected_end(data.start_date, regimen) + + treatment = Treatment( + id=uuid.uuid4(), + patient_id=data.patient_id, + prescribed_by=professional.id, + regimen=regimen, + start_date=data.start_date, + expected_end=expected_end, + status=TreatmentStatus.active, + notes=data.notes, + ) + try: + treatment = await self._treatment_repo.create(treatment) + except ConflictError: + raise ConflictError("Paciente já possui um tratamento ativo.") from None + + return TreatmentResponseV1.model_validate(treatment) diff --git a/backend/src/pequi/use_cases/v1/get_adherence.py b/backend/src/pequi/use_cases/v1/get_adherence.py new file mode 100644 index 0000000..0e53163 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/get_adherence.py @@ -0,0 +1,49 @@ +import uuid + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.treatment import AdherenceSnapshotResponseV1 +from pequi.use_cases.v1.get_treatment import _assert_access + + +class GetAdherenceV1UseCase: + """v1 — lê snapshot sem recalcular; paciente ou profissional.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + ) -> AdherenceSnapshotResponseV1: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + await _assert_access( + actor_user_id=actor_user_id, + actor_role=actor_role, + treatment=treatment, + patient_repo=self._patient_repo, + professional_repo=self._professional_repo, + ) + + snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment_id) + if snapshot is None: + raise NotFoundError( + "AdherenceSnapshot", + "Nenhum snapshot calculado ainda para este tratamento.", + ) + + return AdherenceSnapshotResponseV1.model_validate(snapshot) diff --git a/backend/src/pequi/use_cases/v1/get_treatment.py b/backend/src/pequi/use_cases/v1/get_treatment.py new file mode 100644 index 0000000..05edbc7 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/get_treatment.py @@ -0,0 +1,74 @@ +import uuid + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.treatment import TreatmentResponseV1 + + +class GetTreatmentV1UseCase: + """v1 — paciente ou profissional da mesma unidade.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + ) -> TreatmentResponseV1: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + if treatment.prescribed_by is None: + raise NotFoundError("Treatment", str(treatment_id)) + + await _assert_access( + actor_user_id=actor_user_id, + actor_role=actor_role, + treatment=treatment, + patient_repo=self._patient_repo, + professional_repo=self._professional_repo, + ) + + return TreatmentResponseV1.model_validate(treatment) + + +async def _assert_access( + *, + actor_user_id: uuid.UUID, + actor_role: str, + treatment, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, +) -> None: + if actor_role == "patient": + patient = await patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + elif actor_role == "health_professional": + professional = await professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await patient_repo.get_by_id(treatment.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(treatment.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + else: + raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/v1/register_dose.py b/backend/src/pequi/use_cases/v1/register_dose.py new file mode 100644 index 0000000..6dd9897 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/register_dose.py @@ -0,0 +1,127 @@ +import uuid + +from pequi.core.exceptions import ( + ConflictError, + ForbiddenError, + NotFoundError, + ValidationFailedError, +) +from pequi.models.dose_log import DoseLog +from pequi.models.treatment import TreatmentStatus +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.dose_log import DoseLogCreateV1, DoseLogResponseV1 + + +class RegisterDoseV1UseCase: + """v1 — paciente ou profissional; contrato legado com campos supervisionados.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + journey_event_repo: JourneyEventRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + self._journey_event_repo = journey_event_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + data: DoseLogCreateV1, + ) -> DoseLogResponseV1: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + registered_by: uuid.UUID | None = None + + if actor_role == "patient": + await self._validate_patient_access( + actor_user_id=actor_user_id, + treatment=treatment, + data=data, + ) + elif actor_role == "health_professional": + registered_by = await self._validate_professional_access( + actor_user_id=actor_user_id, + treatment=treatment, + data=data, + ) + else: + raise ForbiddenError("Acesso negado.") + + dose_log = DoseLog( + id=uuid.uuid4(), + treatment_id=treatment_id, + drug_name=data.drug_name, + expected_at=data.expected_at, + taken_at=data.taken_at, + skipped=data.skipped, + skip_reason=data.skip_reason, + supervised=data.supervised, + registered_by=registered_by, + ) + try: + dose_log = await self._dose_repo.create(dose_log) + except ConflictError: + raise ConflictError( + f"Dose duplicada: já existe registro para '{data.drug_name}' " + f"em {data.expected_at.isoformat()} neste tratamento." + ) from None + await self._journey_event_repo.create_for_dose(treatment.patient_id, dose_log) + + return DoseLogResponseV1.model_validate(dose_log) + + async def _validate_patient_access( + self, + actor_user_id: uuid.UUID, + treatment, + data: DoseLogCreateV1, + ) -> None: + if data.supervised and not data.via_consultation: + raise ForbiddenError( + "Dose supervisionada só pode ser registrada ao informar uma consulta realizada." + ) + + patient = await self._patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + if treatment.status != TreatmentStatus.active: + raise ValidationFailedError( + "Autoregistro permitido apenas em tratamentos com status 'active'." + ) + + async def _validate_professional_access( + self, + actor_user_id: uuid.UUID, + treatment, + data: DoseLogCreateV1, + ) -> uuid.UUID: + professional = await self._professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await self._patient_repo.get_by_id(treatment.patient_id) + if patient is None or patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + if data.supervised and professional is None: + raise ValidationFailedError( + "Dose supervisionada deve ser registrada por um profissional." + ) + + return professional.user_id diff --git a/backend/tests/e2e/test_journey_endpoint.py b/backend/tests/e2e/test_journey_endpoint.py new file mode 100644 index 0000000..0a04573 --- /dev/null +++ b/backend/tests/e2e/test_journey_endpoint.py @@ -0,0 +1,60 @@ +from datetime import date +from uuid import uuid4 + +import pytest + +from pequi.core.auth import create_access_token +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User + + +@pytest.mark.asyncio +async def test_patient_journey_endpoint_matches_frontend_contract( + create_tables, db_session, async_client +): + user = User( + id=uuid4(), + email=f"journey-endpoint-{uuid4()}@test.com", + username=f"journey_{str(uuid4())[:8]}", + hashed_password="$2b$12$placeholder", + full_name="Journey Patient", + role="patient", + ) + unit = HealthUnit(id=uuid4(), name="UBS Journey", city="Cidade", state="SP", cnes="12345678901") + db_session.add_all([user, unit]) + await db_session.flush() + patient = PatientProfile( + id=uuid4(), + user_id=user.id, + health_unit_id=unit.id, + classification="PB", + ) + db_session.add(patient) + await db_session.flush() + db_session.add( + Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 1, 1), + expected_end=date(2026, 7, 1), + status=TreatmentStatus.active, + ) + ) + await db_session.flush() + + token = create_access_token(str(user.id), role="patient") + response = await async_client.get( + "/v1/patients/me/journey", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["patient"]["id"] == str(patient.id) + assert payload["treatment"]["regimen"] == "PB" + assert payload["months"][0]["month_number"] == 6 + assert "is_current" in payload["months"][0] + assert payload["summary"]["total_months"] == 6 diff --git a/backend/tests/e2e/test_patient_treatment_endpoints.py b/backend/tests/e2e/test_patient_treatment_endpoints.py new file mode 100644 index 0000000..00b0b04 --- /dev/null +++ b/backend/tests/e2e/test_patient_treatment_endpoints.py @@ -0,0 +1,105 @@ +"""E2E — endpoints de tratamento no router de pacientes.""" + +from datetime import date + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.auth import hash_password +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User + +pytestmark = pytest.mark.asyncio + + +async def _login_patient(async_client: AsyncClient, *, email: str, password: str) -> str: + response = await async_client.post( + "/v1/auth/login", + json={"identifier": email, "password": password}, + ) + assert response.status_code == 200 + return response.json()["access_token"] + + +async def _seed_patient_with_active_treatment( + db_session: AsyncSession, + *, + email: str = "treatment-e2e@example.com", +) -> tuple[User, Treatment]: + user = User( + email=email, + username=email.split("@")[0], + hashed_password=hash_password("patientpassword"), + full_name="Patient E2E", + role="patient", + ) + db_session.add(user) + await db_session.flush() + + unit = HealthUnit(name="UBS E2E", city="Cidade", state="SP", cnes="12345678901") + db_session.add(unit) + await db_session.flush() + + patient = PatientProfile( + user_id=user.id, + health_unit_id=unit.id, + date_of_birth=date(1990, 1, 1), + classification="PB", + ) + db_session.add(patient) + await db_session.flush() + + treatment = Treatment( + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2025, 1, 10), + expected_end=date(2025, 7, 10), + status=TreatmentStatus.active, + ) + db_session.add(treatment) + await db_session.flush() + return user, treatment + + +async def test_get_active_treatment_returns_200( + create_tables, + async_client: AsyncClient, + db_session: AsyncSession, +): + user, treatment = await _seed_patient_with_active_treatment(db_session) + token = await _login_patient(async_client, email=user.email, password="patientpassword") + + response = await async_client.get( + "/v1/patients/me/active-treatment", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(treatment.id) + assert data["regimen"] == "PB" + + +async def test_get_medication_checklist_returns_200( + create_tables, + async_client: AsyncClient, + db_session: AsyncSession, +): + user, treatment = await _seed_patient_with_active_treatment( + db_session, + email="checklist-e2e@example.com", + ) + token = await _login_patient(async_client, email=user.email, password="patientpassword") + + response = await async_client.get( + "/v1/patients/me/medication-checklist", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["active_treatment_id"] == str(treatment.id) + assert data["can_register_doses"] is True diff --git a/backend/tests/e2e/test_v1_treatment_compatibility.py b/backend/tests/e2e/test_v1_treatment_compatibility.py new file mode 100644 index 0000000..a461826 --- /dev/null +++ b/backend/tests/e2e/test_v1_treatment_compatibility.py @@ -0,0 +1,131 @@ +"""E2E — contrato legado v1 de tratamentos e doses.""" + +from datetime import UTC, date, datetime +from uuid import UUID, uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.auth import create_access_token, hash_password +from pequi.models.dose_log import DoseLog +from pequi.models.health_professional import HealthProfessional +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.user import User + +pytestmark = pytest.mark.asyncio + + +async def _seed_v1_actors( + db_session: AsyncSession, +) -> tuple[User, User, PatientProfile, HealthProfessional]: + unit = HealthUnit( + id=uuid4(), + name="UBS V1", + city="Cidade", + state="SP", + cnes=str(uuid4())[:11], + ) + db_session.add(unit) + await db_session.flush() + + patient_user = User( + id=uuid4(), + email=f"patient-v1-{uuid4()}@test.com", + username=f"patient_v1_{uuid4().hex[:8]}", + hashed_password=hash_password("patientpassword"), + full_name="Patient V1", + role="patient", + ) + professional_user = User( + id=uuid4(), + email=f"professional-v1-{uuid4()}@test.com", + username=f"professional_v1_{uuid4().hex[:8]}", + hashed_password=hash_password("professionalpassword"), + full_name="Professional V1", + role="health_professional", + ) + db_session.add_all([patient_user, professional_user]) + await db_session.flush() + + patient = PatientProfile( + id=uuid4(), + user_id=patient_user.id, + health_unit_id=unit.id, + date_of_birth=date(1990, 1, 1), + ) + professional = HealthProfessional( + id=uuid4(), + user_id=professional_user.id, + health_unit_id=unit.id, + ) + db_session.add_all([patient, professional]) + await db_session.flush() + return patient_user, professional_user, patient, professional + + +async def test_v1_treatment_contract_preserves_professional_fields( + create_tables, + async_client: AsyncClient, + db_session: AsyncSession, +): + patient_user, professional_user, patient, professional = await _seed_v1_actors(db_session) + professional_token = create_access_token(professional_user.id, "health_professional") + patient_token = create_access_token(patient_user.id, "patient") + + create_response = await async_client.post( + "/v1/treatments", + headers={"Authorization": f"Bearer {professional_token}"}, + json={ + "patient_id": str(patient.id), + "regimen": "PB", + "start_date": "2026-01-01", + "notes": "Tratamento legado", + }, + ) + assert create_response.status_code == 201 + created = create_response.json() + assert created["prescribed_by"] == str(professional.id) + + treatment_id = created["id"] + patient_get = await async_client.get( + f"/v1/treatments/{treatment_id}", + headers={"Authorization": f"Bearer {patient_token}"}, + ) + assert patient_get.status_code == 200 + assert patient_get.json()["prescribed_by"] == str(professional.id) + + professional_get = await async_client.get( + f"/v1/treatments/{treatment_id}", + headers={"Authorization": f"Bearer {professional_token}"}, + ) + assert professional_get.status_code == 200 + assert professional_get.json()["prescribed_by"] == str(professional.id) + + dose_response = await async_client.post( + f"/v1/treatments/{treatment_id}/doses", + headers={"Authorization": f"Bearer {professional_token}"}, + json={ + "drug_name": "Rifampicina", + "expected_at": "2026-02-01T08:00:00Z", + "taken_at": "2026-02-01T08:15:00Z", + "supervised": True, + }, + ) + assert dose_response.status_code == 201 + dose = dose_response.json() + assert dose["supervised"] is True + assert dose["registered_by"] == str(professional_user.id) + + persisted = await db_session.scalar( + select(DoseLog).where( + DoseLog.treatment_id == UUID(treatment_id), + DoseLog.drug_name == "Rifampicina", + DoseLog.expected_at == datetime(2026, 2, 1, 8, 0, tzinfo=UTC), + ) + ) + assert persisted is not None + assert persisted.supervised is True + assert persisted.registered_by == professional_user.id diff --git a/backend/tests/integration/test_account_deletion.py b/backend/tests/integration/test_account_deletion.py index 1fa0d9f..46b85f9 100644 --- a/backend/tests/integration/test_account_deletion.py +++ b/backend/tests/integration/test_account_deletion.py @@ -74,10 +74,8 @@ async def _create_professional(db_session: AsyncSession, unit_id) -> HealthProfe async def test_delete_account_blocks_active_treatment(create_tables, db_session: AsyncSession): user, patient = await _create_patient(db_session) - professional = await _create_professional(db_session, patient.health_unit_id) treatment = Treatment( patient_id=patient.id, - prescribed_by=professional.id, regimen=TreatmentRegimen.PB, start_date=date.today(), expected_end=date.today() + timedelta(days=180), diff --git a/backend/tests/integration/test_account_export_and_consents.py b/backend/tests/integration/test_account_export_and_consents.py index 9613e5d..e1b6445 100644 --- a/backend/tests/integration/test_account_export_and_consents.py +++ b/backend/tests/integration/test_account_export_and_consents.py @@ -11,6 +11,7 @@ from pequi.models.community import CommunityAnonymousMap, CommunityPost from pequi.models.consent import Consent from pequi.models.health_unit import HealthUnit +from pequi.models.journey_event import JourneyEvent from pequi.models.patient import PatientProfile from pequi.models.user import User from pequi.schemas.account import ConsentCreate @@ -76,6 +77,15 @@ async def test_export_account_data_includes_profile_clinical_community_and_conse categories=["experience"], ) db_session.add(post) + journey_event = JourneyEvent( + patient_id=patient.id, + event_type="clinical_improvement", + title="Melhora clinica", + description="Evento gerado para a jornada.", + occurred_at=datetime.now(UTC), + event_metadata={"source": "test"}, + ) + db_session.add(journey_event) await db_session.flush() exported = await ExportAccountDataUseCase(db_session).execute( @@ -90,6 +100,7 @@ async def test_export_account_data_includes_profile_clinical_community_and_conse assert exported["body_map_entries"] == [] assert exported["adherence_snapshots"] == [] assert exported["weekly_symptom_summaries"] == [] + assert exported["journey_events"][0]["event_type"] == "clinical_improvement" assert exported["community_posts"][0]["title"] == "Minha jornada" assert exported["community_posts"][0]["categories"] == ["experience"] assert "author_anonymous_id" not in exported["community_posts"][0] diff --git a/backend/tests/integration/test_adherence_worker.py b/backend/tests/integration/test_adherence_worker.py index e0378d1..4ba6afe 100644 --- a/backend/tests/integration/test_adherence_worker.py +++ b/backend/tests/integration/test_adherence_worker.py @@ -91,7 +91,6 @@ async def _create_patient_with_treatment( treatment = Treatment( id=treatment_id, patient_id=patient_id, - prescribed_by=professional_id, regimen="MB", start_date=date(2026, 1, 1), expected_end=date(2026, 12, 31), @@ -240,7 +239,6 @@ async def test_adherence_repo_lists_active_treatments(db_session: AsyncSession): # Criar tratamento ativo active_treatment = Treatment( patient_id=patient_id, - prescribed_by=professional_id, regimen="MB", start_date=date(2026, 1, 1), expected_end=date(2026, 12, 31), @@ -250,7 +248,6 @@ async def test_adherence_repo_lists_active_treatments(db_session: AsyncSession): # Criar tratamento completado completed_treatment = Treatment( patient_id=patient_id, - prescribed_by=professional_id, regimen="PB", start_date=date(2025, 1, 1), expected_end=date(2025, 6, 30), diff --git a/backend/tests/integration/test_alert_after_checkin.py b/backend/tests/integration/test_alert_after_checkin.py index 6ae1395..08c3595 100644 --- a/backend/tests/integration/test_alert_after_checkin.py +++ b/backend/tests/integration/test_alert_after_checkin.py @@ -54,8 +54,8 @@ async def test_missed_doses_alert_when_four_missed_in_week(create_tables, db_ses pu = await _create_user(db_session, email="al2@test.com", role="patient") prof = await _create_user(db_session, email="pr2@test.com", role="health_professional") patient = await _create_patient(db_session, user=pu, health_unit=hu) - professional = await _create_professional(db_session, user=prof, health_unit=hu) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + await _create_professional(db_session, user=prof, health_unit=hu) + treatment = await _create_treatment(db_session, patient=patient) symptom = await _symptom(db_session) now = datetime.now(UTC) for i in range(4): diff --git a/backend/tests/integration/test_dose_flow.py b/backend/tests/integration/test_dose_flow.py index 5584705..f75ba84 100644 --- a/backend/tests/integration/test_dose_flow.py +++ b/backend/tests/integration/test_dose_flow.py @@ -9,7 +9,12 @@ import pytest -from pequi.core.exceptions import ConflictError, ForbiddenError, NotFoundError +from pequi.core.exceptions import ( + ConflictError, + ForbiddenError, + NotFoundError, + ValidationFailedError, +) from pequi.models.dose_log import AdherenceSnapshot from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit @@ -17,17 +22,13 @@ from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus from pequi.models.user import User from pequi.repositories.dose_repo import DoseRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate from pequi.use_cases.get_adherence import GetAdherenceUseCase from pequi.use_cases.register_dose import RegisterDoseUseCase -# --------------------------------------------------------------------------- -# Helpers de fixtures -# --------------------------------------------------------------------------- - async def _create_health_unit(session, *, name: str = "UBS Central") -> HealthUnit: hu = HealthUnit(id=uuid4(), name=name, city="Cidade", state="SP", cnes=str(uuid4())[:11]) @@ -80,13 +81,11 @@ async def _create_treatment( session, *, patient: PatientProfile, - professional: HealthProfessional, status: TreatmentStatus = TreatmentStatus.active, ) -> Treatment: treatment = Treatment( id=uuid4(), patient_id=patient.id, - prescribed_by=professional.id, regimen=TreatmentRegimen.PB, start_date=date(2026, 1, 1), expected_end=date(2026, 7, 1), @@ -102,24 +101,17 @@ def _make_use_case(session) -> RegisterDoseUseCase: TreatmentRepository(session), DoseRepository(session), PatientRepository(session), - HealthProfessionalRepository(session), + JourneyEventRepository(session), ) -# --------------------------------------------------------------------------- -# Testes -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_patient_can_register_daily_dose(create_tables, db_session): """Paciente registra dose diária do próprio tratamento ativo com sucesso.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient1@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof1@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) data = DoseLogCreate( drug_name="Dapsona", @@ -128,129 +120,117 @@ async def test_patient_can_register_daily_dose(create_tables, db_session): ) use_case = _make_use_case(db_session) - result = await use_case.execute(patient_user.id, "patient", treatment.id, data) + result = await use_case.execute(patient_user.id, treatment.id, data) assert result.id is not None assert result.treatment_id == treatment.id assert result.drug_name == "Dapsona" - assert result.supervised is False - assert result.registered_by is None @pytest.mark.asyncio -async def test_duplicate_dose_returns_conflict(create_tables, db_session): - """Dose duplicada (treatment_id + drug_name + expected_at) lança ConflictError.""" +async def test_patient_can_register_monthly_dose(create_tables, db_session): + """Paciente pode registrar dose mensal do próprio tratamento ativo.""" health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient2@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof2@test.com", role="health_professional") + patient_user = await _create_user(db_session, email="patient1b@test.com", role="patient") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) - expected_at = datetime(2026, 3, 1, 8, 0, tzinfo=UTC) - data = DoseLogCreate(drug_name="Clofazimina", expected_at=expected_at) + data = DoseLogCreate( + drug_name="Rifampicina", + expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), + taken_at=datetime(2026, 2, 1, 10, 15, tzinfo=UTC), + ) use_case = _make_use_case(db_session) - await use_case.execute(patient_user.id, "patient", treatment.id, data) + result = await use_case.execute(patient_user.id, treatment.id, data) - with pytest.raises(ConflictError): - await use_case.execute(patient_user.id, "patient", treatment.id, data) + assert result.drug_name == "Rifampicina" + assert result.taken_at is not None @pytest.mark.asyncio -async def test_professional_from_another_unit_cannot_access(create_tables, db_session): - """Profissional de outra unidade não pode registrar dose no tratamento.""" - unit_a = await _create_health_unit(db_session, name="UBS Norte") - unit_b = await _create_health_unit(db_session, name="UBS Sul") - - patient_user = await _create_user(db_session, email="patient3@test.com", role="patient") - prof_a_user = await _create_user( - db_session, email="prof_a@test.com", role="health_professional" - ) - prof_b_user = await _create_user( - db_session, email="prof_b@test.com", role="health_professional" - ) - - patient = await _create_patient(db_session, user=patient_user, health_unit=unit_a) - prof_a = await _create_professional(db_session, user=prof_a_user, health_unit=unit_a) - await _create_professional(db_session, user=prof_b_user, health_unit=unit_b) - - treatment = await _create_treatment(db_session, patient=patient, professional=prof_a) +async def test_duplicate_dose_returns_conflict(create_tables, db_session): + """Dose duplicada (treatment_id + drug_name + expected_at) lança ConflictError.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient2@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient) - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), - ) + expected_at = datetime(2026, 3, 1, 8, 0, tzinfo=UTC) + data = DoseLogCreate(drug_name="Clofazimina", expected_at=expected_at) use_case = _make_use_case(db_session) + await use_case.execute(patient_user.id, treatment.id, data) - with pytest.raises(ForbiddenError): - await use_case.execute(prof_b_user.id, "health_professional", treatment.id, data) + with pytest.raises(ConflictError): + await use_case.execute(patient_user.id, treatment.id, data) @pytest.mark.asyncio -async def test_patient_cannot_register_supervised_dose_without_consultation( - create_tables, - db_session, -): - """Paciente não pode registrar dose supervisionada fora do fluxo de consulta.""" +async def test_active_treatment_unique_index_returns_conflict(create_tables, db_session): health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient4@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof4@test.com", role="health_professional") + patient_user = await _create_user( + db_session, + email=f"active-conflict-{uuid4()}@test.com", + role="patient", + ) patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + await _create_treatment(db_session, patient=patient) - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), - supervised=True, - via_consultation=False, + second_active = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 2, 1), + expected_end=date(2026, 8, 1), + status=TreatmentStatus.active, ) - use_case = _make_use_case(db_session) + with pytest.raises(ConflictError): + await TreatmentRepository(db_session).create(second_active) - with pytest.raises(ForbiddenError): - await use_case.execute(patient_user.id, "patient", treatment.id, data) + inactive = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 2, 1), + expected_end=date(2026, 8, 1), + status=TreatmentStatus.suspended, + ) + created = await TreatmentRepository(db_session).create(inactive) + assert created.id == inactive.id @pytest.mark.asyncio -async def test_patient_registers_supervised_dose_via_consultation(create_tables, db_session): - """Paciente registra dose supervisionada ao informar consulta realizada.""" +async def test_other_patient_cannot_register_dose(create_tables, db_session): + """Paciente não pode registrar dose em tratamento de outro paciente.""" health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient4b@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof4b@test.com", role="health_professional") - patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + owner_user = await _create_user(db_session, email="owner@test.com", role="patient") + other_user = await _create_user(db_session, email="other@test.com", role="patient") + owner = await _create_patient(db_session, user=owner_user, health_unit=health_unit) + await _create_patient(db_session, user=other_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=owner) data = DoseLogCreate( drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), - taken_at=datetime(2026, 2, 1, 10, 15, tzinfo=UTC), - supervised=True, - via_consultation=True, + expected_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), ) use_case = _make_use_case(db_session) - result = await use_case.execute(patient_user.id, "patient", treatment.id, data) - assert result.supervised is True - assert result.registered_by is None + with pytest.raises(ForbiddenError): + await use_case.execute(other_user.id, treatment.id, data) @pytest.mark.asyncio async def test_patient_cannot_register_dose_on_inactive_treatment(create_tables, db_session): - """Paciente não pode autoregistrar em tratamento não-ativo.""" + """Paciente não pode registrar dose em tratamento não-ativo.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient5@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof5@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) treatment = await _create_treatment( db_session, patient=patient, - professional=professional, status=TreatmentStatus.completed, ) @@ -261,10 +241,8 @@ async def test_patient_cannot_register_dose_on_inactive_treatment(create_tables, use_case = _make_use_case(db_session) - from pequi.core.exceptions import ValidationFailedError - with pytest.raises(ValidationFailedError): - await use_case.execute(patient_user.id, "patient", treatment.id, data) + await use_case.execute(patient_user.id, treatment.id, data) @pytest.mark.asyncio @@ -272,10 +250,8 @@ async def test_get_adherence_returns_latest_snapshot(create_tables, db_session): """get_adherence retorna o snapshot mais recente quando disponível.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient6@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof6@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) snapshot = AdherenceSnapshot( id=uuid4(), @@ -294,9 +270,8 @@ async def test_get_adherence_returns_latest_snapshot(create_tables, db_session): use_case = GetAdherenceUseCase( TreatmentRepository(db_session), PatientRepository(db_session), - HealthProfessionalRepository(db_session), ) - result = await use_case.execute(patient_user.id, "patient", treatment.id) + result = await use_case.execute(patient_user.id, treatment.id) assert result.treatment_id == treatment.id assert result.total_doses == 30 @@ -308,40 +283,13 @@ async def test_get_adherence_raises_not_found_when_no_snapshot(create_tables, db """Sem snapshot calculado, get_adherence lança NotFoundError.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient7@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof7@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) use_case = GetAdherenceUseCase( TreatmentRepository(db_session), PatientRepository(db_session), - HealthProfessionalRepository(db_session), ) with pytest.raises(NotFoundError): - await use_case.execute(patient_user.id, "patient", treatment.id) - - -@pytest.mark.asyncio -async def test_professional_registers_supervised_dose(create_tables, db_session): - """Profissional registra dose supervisionada com registered_by preenchido.""" - health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient8@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof8@test.com", role="health_professional") - patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) - - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 9, 0, tzinfo=UTC), - taken_at=datetime(2026, 2, 1, 9, 15, tzinfo=UTC), - supervised=True, - ) - - use_case = _make_use_case(db_session) - result = await use_case.execute(prof_user.id, "health_professional", treatment.id, data) - - assert result.supervised is True - assert result.registered_by == prof_user.id + await use_case.execute(patient_user.id, treatment.id) diff --git a/backend/tests/integration/test_journey_flow.py b/backend/tests/integration/test_journey_flow.py new file mode 100644 index 0000000..14b7560 --- /dev/null +++ b/backend/tests/integration/test_journey_flow.py @@ -0,0 +1,250 @@ +"""Testes de integração para GET /v1/journey (via use case).""" + +from datetime import UTC, date, datetime +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import NotFoundError +from pequi.models.health_appointment import PatientHealthAppointment +from pequi.models.health_unit import HealthUnit +from pequi.models.journey_event import JourneyEvent +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase +from pequi.use_cases.register_dose import RegisterDoseUseCase + + +async def _create_health_unit(session, *, name: str = "UBS Central") -> HealthUnit: + hu = HealthUnit(id=uuid4(), name=name, city="Cidade", state="SP", cnes=str(uuid4())[:11]) + session.add(hu) + await session.flush() + return hu + + +async def _create_user(session, *, email: str) -> User: + username = email.split("@")[0].replace(".", "_")[:30] + user = User( + id=uuid4(), + email=email, + username=username, + hashed_password="$2b$12$placeholder", + full_name="Test User", + role="patient", + ) + session.add(user) + await session.flush() + return user + + +async def _create_patient(session, *, user: User, health_unit: HealthUnit) -> PatientProfile: + patient = PatientProfile( + id=uuid4(), + user_id=user.id, + health_unit_id=health_unit.id, + date_of_birth=date(1985, 3, 10), + classification="PB", + ) + session.add(patient) + await session.flush() + return patient + + +async def _create_treatment(session, *, patient: PatientProfile) -> Treatment: + treatment = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2025, 1, 10), + expected_end=date(2025, 7, 10), + status=TreatmentStatus.active, + ) + session.add(treatment) + await session.flush() + return treatment + + +def _journey_use_case(session) -> GetPatientJourneyUseCase: + return GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + DoseRepository(session), + HealthAppointmentRepository(session), + JourneyEventRepository(session), + ) + + +@pytest.mark.asyncio +async def test_journey_returns_timeline_with_doses(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey1@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient) + + register = RegisterDoseUseCase( + TreatmentRepository(db_session), + DoseRepository(db_session), + PatientRepository(db_session), + JourneyEventRepository(db_session), + ) + await register.execute( + user.id, + treatment.id, + DoseLogCreate( + drug_name="Dapsona", + expected_at=datetime(2025, 2, 5, 8, 0, tzinfo=UTC), + taken_at=datetime(2025, 2, 5, 8, 30, tzinfo=UTC), + ), + ) + + result = await _journey_use_case(db_session).execute(user.id) + + assert result.patient_id == patient.id + assert result.regimen == "PB" + assert result.start_date == date(2025, 1, 10) + assert result.expected_end == date(2025, 7, 10) + assert result.current_month >= 1 + assert len(result.months) == 6 + + dose_events = [ + event for month in result.months for event in month.events if event.type == "dose_taken" + ] + assert len(dose_events) == 1 + assert result.summary.completed_doses == 1 + assert result.summary.adherence_pct is None + persisted_event = await JourneyEventRepository(db_session).get_by_source( + "dose_log", + next( + event.id for event in await DoseRepository(db_session).list_by_treatment(treatment.id) + ), + ) + assert persisted_event is not None + assert persisted_event.event_type == "dose_registered" + + +@pytest.mark.asyncio +async def test_journey_includes_consultation_events(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey2@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + await _create_treatment(db_session, patient=patient) + + appointment = PatientHealthAppointment( + id=uuid4(), + patient_id=patient.id, + appointment_date=date(2025, 2, 20), + appointment_time="09:00", + location="UBS Norte", + appointment_type="consulta", + professional="Dr. Silva", + performed=True, + status="completed", + wants_follow_up_details=False, + ) + db_session.add(appointment) + await db_session.flush() + + result = await _journey_use_case(db_session).execute(user.id) + + consultation_events = [ + event + for month in result.months + for event in month.events + if event.type == "consultation_registered" + ] + assert len(consultation_events) == 1 + + +@pytest.mark.asyncio +async def test_journey_combines_dose_and_consultation_in_same_month(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey-combo@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient) + + register = RegisterDoseUseCase( + TreatmentRepository(db_session), + DoseRepository(db_session), + PatientRepository(db_session), + JourneyEventRepository(db_session), + ) + await register.execute( + user.id, + treatment.id, + DoseLogCreate( + drug_name="Dapsona", + expected_at=datetime(2025, 2, 15, 8, 0, tzinfo=UTC), + taken_at=datetime(2025, 2, 15, 8, 30, tzinfo=UTC), + ), + ) + + appointment = PatientHealthAppointment( + id=uuid4(), + patient_id=patient.id, + appointment_date=date(2025, 2, 20), + appointment_time="09:00", + location="UBS Norte", + appointment_type="consulta", + performed=True, + status="completed", + wants_follow_up_details=False, + ) + db_session.add(appointment) + await db_session.flush() + + result = await _journey_use_case(db_session).execute(user.id) + + month_two = next(month for month in result.months if month.month_number == 2) + types = {event.type for event in month_two.events} + assert "dose_taken" in types + assert "consultation_registered" in types + + +@pytest.mark.asyncio +async def test_journey_raises_not_found_without_active_treatment(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey3@test.com") + await _create_patient(db_session, user=user, health_unit=health_unit) + + with pytest.raises(NotFoundError): + await _journey_use_case(db_session).execute(user.id) + + +@pytest.mark.asyncio +async def test_journey_excludes_events_from_another_treatment(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey-filter@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + active = await _create_treatment(db_session, patient=patient) + old_treatment = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2024, 1, 10), + expected_end=date(2024, 7, 10), + status=TreatmentStatus.completed, + ) + db_session.add(old_treatment) + await db_session.flush() + await JourneyEventRepository(db_session).create( + JourneyEvent( + patient_id=patient.id, + treatment_id=old_treatment.id, + event_type="alert", + title="Evento antigo", + description="Nao pertence ao tratamento ativo.", + occurred_at=datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + ) + ) + + result = await _journey_use_case(db_session).execute(user.id) + + assert result.treatment.id == active.id + assert all(event.title != "Evento antigo" for month in result.months for event in month.events) diff --git a/backend/tests/integration/test_patient_health_appointment.py b/backend/tests/integration/test_patient_health_appointment.py index 64c6960..cc478dd 100644 --- a/backend/tests/integration/test_patient_health_appointment.py +++ b/backend/tests/integration/test_patient_health_appointment.py @@ -1,13 +1,16 @@ -from datetime import date +from datetime import UTC, date, datetime from uuid import uuid4 import pytest +from sqlalchemy import func, select from pequi.core.auth import hash_password +from pequi.models.dose_log import DoseLog +from pequi.models.health_appointment import PatientHealthAppointment from pequi.models.user import User from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.health_appointment import ( @@ -24,7 +27,6 @@ from tests.integration.test_dose_flow import ( _create_health_unit, _create_patient, - _create_professional, _create_treatment, _create_user, ) @@ -34,20 +36,13 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session): health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email=f"appt-{uuid4()}@test.com", role="patient") - prof_user = await _create_user( - db_session, - email=f"prof-{uuid4()}@test.com", - role="health_professional", - ) patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - await _create_treatment(db_session, patient=patient, professional=professional) + await _create_treatment(db_session, patient=patient) patient_repo = PatientRepository(db_session) await SavePatientTreatmentRecordUseCase( patient_repo, TreatmentRepository(db_session), - HealthProfessionalRepository(db_session), ).execute( patient_user.id, PatientTreatmentRecordSave( @@ -62,8 +57,8 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session patient_repo, HealthAppointmentRepository(db_session), TreatmentRepository(db_session), - HealthProfessionalRepository(db_session), DoseRepository(db_session), + JourneyEventRepository(db_session), ) created = await create_uc.execute( patient_user.id, @@ -75,6 +70,7 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session performed=True, follow_up=AppointmentFollowUpDraftIn( register_supervised_dose=True, + update_dose_from_consultation=True, dose_scheme_rifampicina=True, dose_scheme_dapsone=True, ), @@ -95,90 +91,145 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session @pytest.mark.asyncio -async def test_complete_scheduled_appointment_updates_same_row(create_tables, db_session): +async def test_completed_appointment_survives_duplicate_supervised_dose(create_tables, db_session): health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email=f"upd-{uuid4()}@test.com", role="patient") - prof_user = await _create_user( + patient_user = await _create_user( db_session, - email=f"prof-u-{uuid4()}@test.com", - role="health_professional", + email=f"appt-duplicate-{uuid4()}@test.com", + role="patient", ) patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) + + duplicate = DoseLog( + treatment_id=treatment.id, + drug_name="Rifampicina", + expected_at=datetime(2026, 5, 20, 8, 0, tzinfo=UTC), + ) + db_session.add(duplicate) + await db_session.flush() + + created = await CreatePatientHealthAppointmentUseCase( + PatientRepository(db_session), + HealthAppointmentRepository(db_session), + TreatmentRepository(db_session), + DoseRepository(db_session), + JourneyEventRepository(db_session), + ).execute( + patient_user.id, + HealthAppointmentCreate( + appointment_date=date(2026, 5, 20), + appointment_time="09:30", + location="UBS Centro", + appointment_type="dose_supervisionada", + performed=True, + follow_up=AppointmentFollowUpDraftIn( + register_supervised_dose=True, + update_dose_from_consultation=True, + dose_scheme_rifampicina=True, + dose_scheme_dapsone=True, + ), + ), + ) + + appointment_count = await db_session.scalar( + select(func.count()) + .select_from(PatientHealthAppointment) + .where(PatientHealthAppointment.id == created.id) + ) + assert appointment_count == 1 + + doses = ( + ( + await db_session.execute( + select(DoseLog.drug_name).where(DoseLog.treatment_id == treatment.id) + ) + ) + .scalars() + .all() + ) + assert sorted(doses) == ["Dapsona", "Rifampicina"] + + await db_session.refresh(patient) + assert patient.treatment_record["scheme_rifampicina"] is True + assert patient.treatment_record["scheme_dapsone"] is True + + +@pytest.mark.asyncio +async def test_complete_scheduled_appointment_updates_same_row(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email=f"upd-{uuid4()}@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + await _create_treatment(db_session, patient=patient) patient_repo = PatientRepository(db_session) appointment_repo = HealthAppointmentRepository(db_session) treatment_repo = TreatmentRepository(db_session) - professional_repo = HealthProfessionalRepository(db_session) dose_repo = DoseRepository(db_session) + journey_event_repo = JourneyEventRepository(db_session) scheduled = await CreatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ).execute( patient_user.id, HealthAppointmentCreate( - appointment_date=date(2026, 6, 27), - appointment_time="15:30", - location="UBS Centro", + appointment_date=date(2026, 4, 10), + appointment_time="14:00", + location="UBS Sul", appointment_type="consulta", performed=False, ), ) - assert scheduled.status == "scheduled" completed = await UpdatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ).execute( patient_user.id, scheduled.id, HealthAppointmentCreate( - appointment_date=date(2026, 6, 27), - appointment_time="15:30", - location="UBS Centro", + appointment_date=date(2026, 4, 10), + appointment_time="14:00", + location="UBS Sul", appointment_type="consulta", performed=True, - follow_up=AppointmentFollowUpDraftIn(conduct="Retorno em 30 dias"), + notes="Consulta realizada", ), ) assert completed.id == scheduled.id - assert completed.status == "completed" assert completed.performed is True + assert completed.status == "completed" listed = await ListPatientHealthAppointmentsUseCase( patient_repo, appointment_repo, ).execute(patient_user.id) assert len(listed) == 1 - assert listed[0].id == scheduled.id - assert listed[0].status == "completed" + assert listed[0].performed is True @pytest.mark.asyncio async def test_list_appointments_empty_for_new_patient(create_tables, db_session): user = User( - id=uuid4(), email=f"new-{uuid4()}@test.com", - username=f"u_{uuid4().hex[:8]}", - hashed_password=hash_password("senha12345"), + username=f"new_{uuid4().hex[:8]}", + hashed_password=hash_password("secret"), full_name="Novo Paciente", role="patient", ) db_session.add(user) await db_session.flush() - patient_repo = PatientRepository(db_session) listed = await ListPatientHealthAppointmentsUseCase( - patient_repo, + PatientRepository(db_session), HealthAppointmentRepository(db_session), ).execute(user.id) assert listed == [] diff --git a/backend/tests/integration/test_patient_treatment_record.py b/backend/tests/integration/test_patient_treatment_record.py index d45753d..c4672ae 100644 --- a/backend/tests/integration/test_patient_treatment_record.py +++ b/backend/tests/integration/test_patient_treatment_record.py @@ -5,11 +5,9 @@ import pytest -from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit from pequi.models.treatment import TreatmentRegimen, TreatmentStatus from pequi.models.user import User -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.patient_treatment import PatientTreatmentRecordSave @@ -20,7 +18,8 @@ ) -async def _seed_professional(db_session) -> HealthProfessional: +@pytest.mark.asyncio +async def test_save_and_load_treatment_record(create_tables, db_session): unit = HealthUnit( id=uuid4(), name="UBS Teste", @@ -31,31 +30,6 @@ async def _seed_professional(db_session) -> HealthProfessional: db_session.add(unit) await db_session.flush() - user = User( - id=uuid4(), - email=f"prof-{uuid4()}@test.com", - username=f"prof_{uuid4().hex[:8]}", - hashed_password="x", - full_name="Profissional Teste", - role="health_professional", - ) - db_session.add(user) - await db_session.flush() - - professional = HealthProfessional( - id=uuid4(), - user_id=user.id, - health_unit_id=unit.id, - ) - db_session.add(professional) - await db_session.flush() - return professional - - -@pytest.mark.asyncio -async def test_save_and_load_treatment_record(create_tables, db_session): - await _seed_professional(db_session) - user = User( id=uuid4(), email=f"patient-{uuid4()}@test.com", @@ -69,12 +43,10 @@ async def test_save_and_load_treatment_record(create_tables, db_session): patient_repo = PatientRepository(db_session) treatment_repo = TreatmentRepository(db_session) - professional_repo = HealthProfessionalRepository(db_session) save_uc = SavePatientTreatmentRecordUseCase( patient_repo, treatment_repo, - professional_repo, ) payload = PatientTreatmentRecordSave( diagnosis_date=date(2025, 1, 10), diff --git a/backend/tests/integration/test_summary_worker.py b/backend/tests/integration/test_summary_worker.py index 6889214..4e7e258 100644 --- a/backend/tests/integration/test_summary_worker.py +++ b/backend/tests/integration/test_summary_worker.py @@ -88,7 +88,6 @@ async def test_summary_job_processes_active_patients(db_session: AsyncSession, m treatment = Treatment( id=treatment_id, patient_id=patient_id, - prescribed_by=professional_id, regimen="MB", start_date=datetime(2026, 1, 1).date(), expected_end=datetime(2026, 12, 31).date(), diff --git a/backend/tests/unit/test_journey_service.py b/backend/tests/unit/test_journey_service.py new file mode 100644 index 0000000..1bd7399 --- /dev/null +++ b/backend/tests/unit/test_journey_service.py @@ -0,0 +1,297 @@ +"""Testes unitários para JourneyService — sem banco, sem HTTP.""" + +from datetime import UTC, date, datetime +from decimal import Decimal +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.models.treatment import TreatmentRegimen, TreatmentStatus +from pequi.services.journey_service import JourneyService + + +def _treatment(*, regimen=TreatmentRegimen.PB, start=date(2025, 1, 10), end=date(2025, 7, 10)): + return SimpleNamespace( + id=uuid4(), + regimen=regimen, + start_date=start, + expected_end=end, + status=TreatmentStatus.active, + ) + + +def _dose(*, drug="Dapsona", expected=None, taken=None, skipped=False): + return SimpleNamespace( + id=uuid4(), + drug_name=drug, + expected_at=expected or datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + taken_at=taken, + skipped=skipped, + skip_reason=None, + ) + + +class TestJourneyServiceProgress: + def test_current_month_on_start_date(self) -> None: + result = JourneyService.calculate_current_month( + date(2025, 1, 10), + date(2025, 1, 10), + 6, + ) + assert result == 1 + + def test_current_month_third_month(self) -> None: + result = JourneyService.calculate_current_month( + date(2025, 1, 10), + date(2025, 3, 15), + 6, + ) + assert result == 3 + + def test_current_month_capped_at_total(self) -> None: + result = JourneyService.calculate_current_month( + date(2025, 1, 10), + date(2026, 1, 1), + 6, + ) + assert result == 6 + + def test_progress_pct_mid_treatment(self) -> None: + result = JourneyService.calculate_progress_pct( + date(2025, 1, 10), + date(2025, 7, 10), + date(2025, 3, 15), + ) + # 64 dias decorridos de 181 totais (10/jan a 10/jul) + assert result == Decimal("35.4") + + def test_progress_pct_at_start(self) -> None: + result = JourneyService.calculate_progress_pct( + date(2025, 1, 10), + date(2025, 7, 10), + date(2025, 1, 10), + ) + assert result == Decimal("0.0") + + def test_progress_pct_at_end(self) -> None: + result = JourneyService.calculate_progress_pct( + date(2025, 1, 10), + date(2025, 7, 10), + date(2025, 7, 10), + ) + assert result == Decimal("100.0") + + +class TestJourneyServiceTimeline: + def test_dose_taken_appears_as_event(self) -> None: + treatment = _treatment() + taken_at = datetime(2025, 2, 5, 9, 0, tzinfo=UTC) + doses = [_dose(expected=taken_at, taken=taken_at)] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + dose_events = [ + event + for month in journey.months + for event in month.events + if event.type == "dose_taken" + ] + assert len(dose_events) == 1 + assert dose_events[0].title == "Dose tomada" + + def test_dose_skipped_appears_as_event(self) -> None: + treatment = _treatment() + expected = datetime(2025, 2, 5, 9, 0, tzinfo=UTC) + doses = [_dose(expected=expected, skipped=True)] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + skipped_events = [ + event + for month in journey.months + for event in month.events + if event.type == "dose_skipped" + ] + assert len(skipped_events) == 1 + + def test_consultation_appears_as_event(self) -> None: + treatment = _treatment() + appointment = SimpleNamespace( + appointment_date=date(2025, 2, 20), + appointment_type="consulta", + location="UBS Central", + professional="Dr. Silva", + performed=True, + ) + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[], + appointments=[appointment], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + consultation_events = [ + event + for month in journey.months + for event in month.events + if event.type == "consultation_registered" + ] + assert len(consultation_events) == 1 + assert consultation_events[0].title == "Consulta registrada" + + def test_summary_counts_doses(self) -> None: + treatment = _treatment() + taken_at = datetime(2025, 2, 1, 8, 0, tzinfo=UTC) + doses = [ + _dose(expected=taken_at, taken=taken_at), + _dose( + expected=datetime(2025, 2, 2, 8, 0, tzinfo=UTC), + taken=None, + ), + ] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert journey.summary.completed_doses == 1 + assert journey.summary.pending_doses == 1 + assert journey.summary.adherence_pct is None + + def test_summary_without_snapshot_does_not_calculate_adherence(self) -> None: + treatment = _treatment() + doses = [ + _dose( + expected=datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + taken=datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + ), + _dose( + expected=datetime(2025, 2, 2, 8, 0, tzinfo=UTC), + skipped=True, + ), + ] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert journey.summary.completed_doses == 1 + assert journey.summary.skipped_doses == 1 + assert journey.summary.adherence_pct is None + + def test_dose_and_consultation_same_month_sort_without_error(self) -> None: + treatment = _treatment() + taken_at = datetime(2025, 2, 15, 9, 0, tzinfo=UTC) + doses = [_dose(expected=taken_at, taken=taken_at)] + appointment = SimpleNamespace( + appointment_date=date(2025, 2, 20), + appointment_type="consulta", + location="UBS Central", + professional=None, + performed=True, + ) + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[appointment], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + month_two = next(month for month in journey.months if month.month_number == 2) + types = {event.type for event in month_two.events} + assert "dose_taken" in types + assert "consultation_registered" in types + + def test_months_grouped_by_calendar_month(self) -> None: + treatment = _treatment(regimen=TreatmentRegimen.PB) + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[], + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert len(journey.months) == 6 + assert journey.months[0].month_number == 6 + assert journey.months[-1].month_number == 1 + assert next(month for month in journey.months if month.is_current).month_number == 2 + + def test_summary_exposes_frontend_aggregates(self) -> None: + treatment = _treatment() + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[_dose(taken=datetime(2025, 2, 1, 8, 0, tzinfo=UTC))], + appointments=[ + SimpleNamespace( + appointment_date=date(2025, 2, 20), + appointment_type="consulta", + location="UBS Central", + professional=None, + performed=True, + ) + ], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert journey.summary.total_months == 6 + assert journey.summary.current_month == 2 + assert journey.summary.total_consultations == 1 + assert journey.summary.total_doses_registered == 1 + + @pytest.mark.parametrize( + ("regimen", "expected_months"), + [ + (TreatmentRegimen.PB, 6), + (TreatmentRegimen.MB, 12), + ], + ) + def test_total_months_by_regimen(self, regimen, expected_months) -> None: + start = date(2025, 1, 1) + end = date(2025, 7, 1) if regimen == TreatmentRegimen.PB else date(2026, 1, 1) + treatment = _treatment(regimen=regimen, start=start, end=end) + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[], + appointments=[], + adherence_snapshot=None, + today=start, + ) + + assert len(journey.months) == expected_months diff --git a/backend/tests/unit/test_treatment_use_cases.py b/backend/tests/unit/test_treatment_use_cases.py index 353d97d..a083673 100644 --- a/backend/tests/unit/test_treatment_use_cases.py +++ b/backend/tests/unit/test_treatment_use_cases.py @@ -4,7 +4,7 @@ import pytest -from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.core.exceptions import ForbiddenError, NotFoundError, ValidationFailedError from pequi.schemas.treatment import TreatmentCreate from pequi.use_cases.create_treatment import CreateTreatmentUseCase from pequi.use_cases.get_treatment import GetTreatmentUseCase @@ -16,7 +16,6 @@ def _treatment_attrs(**overrides): base = { "id": uuid4(), "patient_id": uuid4(), - "prescribed_by": uuid4(), "regimen": "PB", "start_date": date(2026, 1, 1), "expected_end": date(2026, 7, 1), @@ -30,8 +29,9 @@ def _treatment_attrs(**overrides): class FakeTreatmentRepository: - def __init__(self, treatment=None): + def __init__(self, treatment=None, active=None): self.treatment = treatment + self.active = active self.created = None async def create(self, treatment): @@ -45,6 +45,11 @@ async def get_by_id(self, treatment_id): return None return self.treatment + async def get_active_by_patient_id(self, patient_id): + if self.active is None or self.active.patient_id != patient_id: + return None + return self.active + class FakePatientRepository: def __init__(self, *, by_id=None, by_user_id=None): @@ -62,31 +67,17 @@ async def get_by_user_id(self, user_id): return self.by_user_id -class FakeProfessionalRepository: - def __init__(self, professional=None): - self.professional = professional - - async def get_by_user_id(self, user_id): - if self.professional is None or self.professional.user_id != user_id: - return None - return self.professional - - async def test_create_treatment_calculates_expected_end_and_preserves_notes(): - unit_id = uuid4() - patient = SimpleNamespace(id=uuid4(), health_unit_id=unit_id) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) treatment_repo = FakeTreatmentRepository() use_case = CreateTreatmentUseCase( treatment_repo, - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), + FakePatientRepository(by_user_id=patient), ) result = await use_case.execute( - professional.user_id, + patient.user_id, TreatmentCreate( - patient_id=patient.id, regimen="PB", start_date=date(2026, 1, 31), notes="Tratamento inicial", @@ -94,7 +85,6 @@ async def test_create_treatment_calculates_expected_end_and_preserves_notes(): ) assert result.patient_id == patient.id - assert result.prescribed_by == professional.id assert result.expected_end == date(2026, 7, 31) assert result.status == "active" assert result.notes == "Tratamento inicial" @@ -102,159 +92,81 @@ async def test_create_treatment_calculates_expected_end_and_preserves_notes(): async def test_create_treatment_handles_month_end_for_mb_regimen(): - unit_id = uuid4() - patient = SimpleNamespace(id=uuid4(), health_unit_id=unit_id) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) use_case = CreateTreatmentUseCase( FakeTreatmentRepository(), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), + FakePatientRepository(by_user_id=patient), ) result = await use_case.execute( - professional.user_id, - TreatmentCreate(patient_id=patient.id, regimen="MB", start_date=date(2024, 2, 29)), + patient.user_id, + TreatmentCreate(regimen="MB", start_date=date(2024, 2, 29)), ) assert result.expected_end == date(2025, 2, 28) assert result.regimen == "MB" -async def test_create_treatment_requires_existing_professional_profile(): - use_case = CreateTreatmentUseCase( - FakeTreatmentRepository(), - FakePatientRepository(), - FakeProfessionalRepository(None), - ) - - with pytest.raises(NotFoundError): - await use_case.execute( - uuid4(), - TreatmentCreate(patient_id=uuid4(), regimen="PB", start_date=date(2026, 1, 1)), - ) - - async def test_create_treatment_requires_existing_patient_profile(): - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) use_case = CreateTreatmentUseCase( FakeTreatmentRepository(), - FakePatientRepository(by_id=None), - FakeProfessionalRepository(professional), + FakePatientRepository(by_user_id=None), ) with pytest.raises(NotFoundError): await use_case.execute( - professional.user_id, - TreatmentCreate(patient_id=uuid4(), regimen="PB", start_date=date(2026, 1, 1)), + uuid4(), + TreatmentCreate(regimen="PB", start_date=date(2026, 1, 1)), ) -async def test_create_treatment_rejects_patient_from_another_unit(): - patient = SimpleNamespace(id=uuid4(), health_unit_id=uuid4()) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) +async def test_create_treatment_rejects_when_active_treatment_exists(): + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) + active = SimpleNamespace(id=uuid4(), patient_id=patient.id, status="active") use_case = CreateTreatmentUseCase( - FakeTreatmentRepository(), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), + FakeTreatmentRepository(active=active), + FakePatientRepository(by_user_id=patient), ) - with pytest.raises(ForbiddenError): + with pytest.raises(ValidationFailedError): await use_case.execute( - professional.user_id, - TreatmentCreate(patient_id=patient.id, regimen="PB", start_date=date(2026, 1, 1)), + patient.user_id, + TreatmentCreate(regimen="PB", start_date=date(2026, 1, 1)), ) async def test_get_treatment_allows_patient_owner(): - patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) use_case = GetTreatmentUseCase( FakeTreatmentRepository(treatment), FakePatientRepository(by_user_id=patient), - FakeProfessionalRepository(), ) - result = await use_case.execute(patient.user_id, "patient", treatment.id) + result = await use_case.execute(patient.user_id, treatment.id) assert result.id == treatment.id assert result.patient_id == patient.id async def test_get_treatment_rejects_patient_that_is_not_owner(): - owner = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - actor = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=owner.health_unit_id) + owner = SimpleNamespace(id=uuid4(), user_id=uuid4()) + actor = SimpleNamespace(id=uuid4(), user_id=uuid4()) treatment = SimpleNamespace(**_treatment_attrs(patient_id=owner.id)) use_case = GetTreatmentUseCase( FakeTreatmentRepository(treatment), FakePatientRepository(by_user_id=actor), - FakeProfessionalRepository(), - ) - - with pytest.raises(ForbiddenError): - await use_case.execute(actor.user_id, "patient", treatment.id) - - -async def test_get_treatment_allows_professional_from_same_unit(): - unit_id = uuid4() - patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) - treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), - ) - - result = await use_case.execute(professional.user_id, "health_professional", treatment.id) - - assert result.id == treatment.id - - -async def test_get_treatment_rejects_professional_from_another_unit(): - patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), - ) - - with pytest.raises(ForbiddenError): - await use_case.execute(professional.user_id, "health_professional", treatment.id) - - -async def test_get_treatment_rejects_unknown_actor_role(): - treatment = SimpleNamespace(**_treatment_attrs()) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(), - FakeProfessionalRepository(), ) with pytest.raises(ForbiddenError): - await use_case.execute(uuid4(), "admin", treatment.id) + await use_case.execute(actor.user_id, treatment.id) async def test_get_treatment_raises_not_found_for_missing_treatment(): use_case = GetTreatmentUseCase( FakeTreatmentRepository(None), FakePatientRepository(), - FakeProfessionalRepository(), - ) - - with pytest.raises(NotFoundError): - await use_case.execute(uuid4(), "patient", uuid4()) - - -async def test_get_treatment_raises_not_found_when_treatment_patient_disappears(): - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - treatment = SimpleNamespace(**_treatment_attrs()) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(by_id=None), - FakeProfessionalRepository(professional), ) with pytest.raises(NotFoundError): - await use_case.execute(professional.user_id, "health_professional", treatment.id) + await use_case.execute(uuid4(), uuid4()) diff --git a/docs/milestones/M3-treatments-doses.md b/docs/milestones/M3-treatments-doses.md index 4c6df06..0847c64 100644 --- a/docs/milestones/M3-treatments-doses.md +++ b/docs/milestones/M3-treatments-doses.md @@ -1,101 +1,63 @@ -# M3 — Treatments & Doses +# M3 - Treatments, Doses & Journey -> **Status:** 🔜 Pendente +> **Status:** Em implementacao > **Depende de:** M2 -> **Bloqueado por:** — ## Objetivo -Modelar o tratamento poliquimioterápico (MDT) do paciente com hanseníase — esquemas PB (6 meses) e MB (12 meses) —, o registro diário de doses e o cálculo de adesão. Ao final, é possível registrar doses tomadas/puladas e consultar o percentual de adesão. +Permitir que o paciente gerencie seu tratamento PB/MB, registre doses e acompanhe uma jornada +mensal unificada. Profissionais continuam podendo criar tratamentos e registrar doses +supervisionadas pelo contrato legado `/v1`. -## Modelo de dados +## Modelo +- `treatments.prescribed_by` e opcional para tratamentos patient-first criados em `/v2`. +- Campos profissionais permanecem disponiveis para compatibilidade `/v1`. +- Apenas um tratamento ativo e permitido por paciente. +- `dose_logs` impede duplicidade por tratamento, medicamento e horario esperado. +- Adesao e lida exclusivamente de `adherence_snapshots`. + +### Journey events + +`journey_events` persiste eventos clinicos unificados: + +```text +id, patient_id, treatment_id, event_type, title, description, +occurred_at, metadata, source_type, source_id, created_at ``` -symptoms ← catálogo seed-only -├── id UUID PK -├── name TEXT NOT NULL -├── category ENUM('dermatological','neurological','systemic') -└── description TEXT - -treatments -├── id UUID PK -├── patient_id UUID FK → patient_profiles(id) ON DELETE RESTRICT -├── prescribed_by UUID FK → health_professionals(id) ON DELETE RESTRICT -├── regimen ENUM('PB','MB') NOT NULL -├── start_date DATE NOT NULL -├── expected_end DATE NOT NULL ← calculado: PB+6m / MB+12m -├── status ENUM('active','completed','abandoned','suspended') -├── notes TEXT -├── created_at TIMESTAMPTZ -├── updated_at TIMESTAMPTZ -└── deleted_at TIMESTAMPTZ NULL - -dose_schedules ← um registro por fármaco por mês -├── id UUID PK -├── treatment_id UUID FK → treatments(id) ON DELETE RESTRICT -├── drug_name TEXT NOT NULL ← ex: "Rifampicina", "Dapsona", "Clofazimina" -├── frequency ENUM('daily','monthly_supervised') -├── dose_mg NUMERIC(6,2) -└── month_number SMALLINT ← 1..12 - -dose_logs -├── id UUID PK -├── treatment_id UUID FK → treatments(id) ON DELETE RESTRICT -├── drug_name TEXT NOT NULL -├── expected_at TIMESTAMPTZ NOT NULL -├── taken_at TIMESTAMPTZ NULL -├── skipped BOOLEAN DEFAULT false -├── skip_reason TEXT NULL -├── supervised BOOLEAN DEFAULT false ← dose supervisionada (mensal) -├── registered_by UUID NULL FK → users(id) ← profissional ou null (autoregistro) -└── created_at TIMESTAMPTZ - -adherence_snapshots ← calculado por worker, nunca em tempo real -├── id UUID PK -├── patient_id UUID FK → patient_profiles(id) -├── treatment_id UUID FK → treatments(id) -├── period_start DATE -├── period_end DATE -├── total_doses INT -├── taken_doses INT -├── adherence_pct NUMERIC(5,2) -└── calculated_at TIMESTAMPTZ -``` -## Arquivos criados - -| Camada | Arquivo | -|--------|---------| -| Models | `models/treatment.py`, `models/dose_log.py`, `models/symptom.py` | -| Schemas | `schemas/treatment.py`, `schemas/dose_log.py` | -| Repositories | `repositories/treatment_repo.py`, `repositories/dose_repo.py` | -| Services | `services/adherence_service.py` | -| Use Cases | `use_cases/register_dose.py`, `use_cases/get_adherence.py` | -| Router | `routers/treatment.py` | -| Tests | `tests/unit/test_adherence_service.py`, `tests/integration/test_dose_flow.py` | -| Bruno | `bruno/treatment/`, `bruno/dose/` | -| Migration | `alembic/versions/003_create_treatments.py` | - -## Endpoints - -| Método | Path | Rate Limit | Auth | -|--------|------|-----------|------| -| `POST` | `/v1/treatments` | 10/min | professional | -| `GET` | `/v1/treatments/{id}` | 100/min | patient/professional | -| `POST` | `/v1/treatments/{id}/doses` | 20/min | patient/professional | -| `GET` | `/v1/treatments/{id}/adherence` | 100/min | patient/professional | -| `GET` | `/v1/symptoms` | 200/min | any authenticated | - -## Regras de negócio - -- Adesão **nunca** calculada em tempo real — lida de `adherence_snapshots` -- `CASCADE DELETE` proibido em `dose_logs` e `treatments` -- Dose supervisionada mensal deve ser registrada por profissional (campo `registered_by` não nulo) -- Paciente só pode autoregistrar doses diárias do próprio tratamento ativo - -## Critérios de aceite - -- [ ] `AdherenceService.calculate_pct` cobre casos: 0%, 33.33%, 100% -- [ ] Registro de dose duplicada (mesma `expected_at` + `drug_name`) retorna 409 -- [ ] Profissional de outra unidade não acessa o tratamento -- [ ] Testes unitários e de integração passando +Tipos suportados pela modelagem incluem `consultation`, `dose_registered`, `treatment_started`, +`treatment_completed`, `clinical_improvement`, `clinical_worsening` e `alert`. + +O registro de uma dose cria automaticamente um evento `dose_registered` na mesma transacao. +`source_type` e `source_id` permitem idempotencia e integracao futura por workers de check-in. + +## Journey + +| Metodo | Path | Auth | Limite | +|---|---|---|---| +| `GET` | `/v1/patients/me/journey` | patient | 100/min | +| `GET` | `/v2/journey` | patient | 100/min | + +A resposta inclui blocos `patient`, `treatment` e `summary`, meses em ordem decrescente, +`month_number`, `is_current`, eventos unificados, progresso, consultas e doses registradas. + +## Regras + +- Nao calcular adesao em tempo real. +- Nao usar `CASCADE DELETE` em dados clinicos. +- Paciente acessa somente seu proprio tratamento e sua propria jornada. +- Dose duplicada retorna conflito sem desfazer outras alteracoes da transacao. +- Eventos futuros de check-in devem ser persistidos por worker em `journey_events`. + +## Criterios de aceite + +- [x] Tratamentos PB e MB calculam duracao esperada. +- [x] Dose duplicada retorna conflito. +- [x] Dose registrada cria evento persistido na Journey. +- [x] Journey agrupa eventos pelo mes correto. +- [x] Journey identifica o mes atual. +- [x] Journey retorna meses em ordem decrescente. +- [x] Resumo retorna progresso, consultas e doses registradas. +- [x] Modelagem suporta eventos clinicos futuros. +- [x] Testes unitarios, integracao e E2E cobrem o fluxo principal. From 16f987dfd1d81ffb78e81ad32c59567386052f82 Mon Sep 17 00:00:00 2001 From: Leila Biggi <87096464+lawtherea@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:17:48 -0300 Subject: [PATCH 60/69] PEQ-167: Changing localhost to API (#73) --- .../education-article-page.spec.ts | 3 ++- frontend/src/app/features/education/education.spec.ts | 10 ++++++---- .../education/services/articles.service.spec.ts | 8 +++++--- .../features/education/services/articles.service.ts | 3 ++- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts b/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts index bb9f70b..26da619 100644 --- a/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts +++ b/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts @@ -3,6 +3,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { ActivatedRoute, provideRouter, Router, convertToParamMap } from '@angular/router'; import { of } from 'rxjs'; +import { environment } from '../../../../environments/environment'; import { EducationArticlePage } from './education-article-page'; import { Education } from '../education'; import type { Article } from '../models/article.models'; @@ -54,7 +55,7 @@ describe('EducationArticlePage', () => { fixture = TestBed.createComponent(EducationArticlePage); fixture.detectChanges(); - const req = httpMock.expectOne('http://localhost:8000/v1/articles/cuidados-diarios'); + const req = httpMock.expectOne(`${environment.apiUrl}/v1/articles/cuidados-diarios`); req.flush(mockArticle); fixture.detectChanges(); }); diff --git a/frontend/src/app/features/education/education.spec.ts b/frontend/src/app/features/education/education.spec.ts index 7af07aa..d200f70 100644 --- a/frontend/src/app/features/education/education.spec.ts +++ b/frontend/src/app/features/education/education.spec.ts @@ -3,6 +3,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { provideRouter, Router } from '@angular/router'; import { By } from '@angular/platform-browser'; +import { environment } from '../../../environments/environment'; import { Education } from './education'; import { EducationArticlePage } from './education-article-page/education-article-page'; import type { Article, ArticleListResponse } from './models/article.models'; @@ -47,20 +48,21 @@ const mockArticles: Article[] = [ ]; describe('Education', () => { + const baseUrl = `${environment.apiUrl}/v1/articles`; let component: Education; let fixture: ComponentFixture; let httpMock: HttpTestingController; let router: Router; function flushInitialRequests(list: ArticleListResponse = { items: mockArticles, total: 2 }): void { - const tagsReq = httpMock.expectOne('http://localhost:8000/v1/articles/tags'); + const tagsReq = httpMock.expectOne(`${baseUrl}/tags`); tagsReq.flush([ { id: 't1', name: 'cuidados' }, { id: 't2', name: 'tratamento' }, ]); const listReq = httpMock.expectOne( - (r) => r.url === 'http://localhost:8000/v1/articles' && r.params.get('category') === 'education' + (r) => r.url === baseUrl && r.params.get('category') === 'education' ); listReq.flush(list); } @@ -117,7 +119,7 @@ describe('Education', () => { const req = httpMock.expectOne( (r) => - r.url === 'http://localhost:8000/v1/articles' && + r.url === baseUrl && r.params.get('tag') === 'cuidados' && r.params.get('category') === 'education' ); @@ -135,7 +137,7 @@ describe('Education', () => { const req = httpMock.expectOne( (r) => - r.url === 'http://localhost:8000/v1/articles' && + r.url === baseUrl && r.params.get('search') === 'adesão' ); req.flush({ items: [mockArticles[1]], total: 1 }); diff --git a/frontend/src/app/features/education/services/articles.service.spec.ts b/frontend/src/app/features/education/services/articles.service.spec.ts index 70dc3f3..ffcd898 100644 --- a/frontend/src/app/features/education/services/articles.service.spec.ts +++ b/frontend/src/app/features/education/services/articles.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { environment } from '../../../../environments/environment'; import { ArticlesService } from './articles.service'; import type { Article, ArticleListResponse } from '../models/article.models'; @@ -24,6 +25,7 @@ const mockArticle: Article = { }; describe('ArticlesService', () => { + const baseUrl = `${environment.apiUrl}/v1/articles`; let service: ArticlesService; let httpMock: HttpTestingController; @@ -48,7 +50,7 @@ describe('ArticlesService', () => { const req = httpMock.expectOne( (r) => - r.url === 'http://localhost:8000/v1/articles' && + r.url === baseUrl && r.params.get('category') === 'education' && r.params.get('tag') === 'cuidados' && r.params.get('search') === 'pele' && @@ -60,7 +62,7 @@ describe('ArticlesService', () => { it('should get article by slug', () => { service.getArticle('cuidados-com-a-pele').subscribe((data) => expect(data).toEqual(mockArticle)); - const req = httpMock.expectOne('http://localhost:8000/v1/articles/cuidados-com-a-pele'); + const req = httpMock.expectOne(`${baseUrl}/cuidados-com-a-pele`); req.flush(mockArticle); }); @@ -69,7 +71,7 @@ describe('ArticlesService', () => { service.listTags().subscribe((data) => expect(data).toEqual(tags)); - const req = httpMock.expectOne('http://localhost:8000/v1/articles/tags'); + const req = httpMock.expectOne(`${baseUrl}/tags`); req.flush(tags); }); }); diff --git a/frontend/src/app/features/education/services/articles.service.ts b/frontend/src/app/features/education/services/articles.service.ts index 57bcf63..23e3cd9 100644 --- a/frontend/src/app/features/education/services/articles.service.ts +++ b/frontend/src/app/features/education/services/articles.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; import type { Article, ArticleListResponse, @@ -11,7 +12,7 @@ import type { @Injectable({ providedIn: 'root' }) export class ArticlesService { private readonly http = inject(HttpClient); - private readonly baseUrl = 'http://localhost:8000/v1/articles'; + private readonly baseUrl = `${environment.apiUrl}/v1/articles`; listArticles(params: ListArticlesParams = {}): Observable { let httpParams = new HttpParams(); From 736a6cd2431b9994191d19f797cb1c19bf357d59 Mon Sep 17 00:00:00 2001 From: rafaellucian0 Date: Mon, 8 Jun 2026 23:35:32 -0300 Subject: [PATCH 61/69] test: add integration tests for dose registration and adherence retrieval flows --- backend/tests/integration/test_dose_flow.py | 39 --------------------- 1 file changed, 39 deletions(-) diff --git a/backend/tests/integration/test_dose_flow.py b/backend/tests/integration/test_dose_flow.py index eb0c777..3595ba7 100644 --- a/backend/tests/integration/test_dose_flow.py +++ b/backend/tests/integration/test_dose_flow.py @@ -16,7 +16,6 @@ ValidationFailedError, ) from pequi.models.dose_log import AdherenceSnapshot -from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit from pequi.models.patient import PatientProfile from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus @@ -64,19 +63,6 @@ async def _create_patient(session, *, user: User, health_unit: HealthUnit) -> Pa return patient -async def _create_professional( - session, *, user: User, health_unit: HealthUnit -) -> HealthProfessional: - professional = HealthProfessional( - id=uuid4(), - user_id=user.id, - health_unit_id=health_unit.id, - ) - session.add(professional) - await session.flush() - return professional - - async def _create_treatment( session, *, @@ -222,31 +208,6 @@ async def test_other_patient_cannot_register_dose(create_tables, db_session): await use_case.execute(other_user.id, treatment.id, data) -@pytest.mark.asyncio -async def test_patient_registers_supervised_dose_via_consultation(create_tables, db_session): - """Paciente registra dose supervisionada ao informar consulta realizada.""" - health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient4b@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof4b@test.com", role="health_professional") - patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) - - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), - taken_at=datetime(2026, 2, 1, 10, 15, tzinfo=UTC), - supervised=True, - via_consultation=True, - ) - - use_case = _make_use_case(db_session) - result = await use_case.execute(patient_user.id, "patient", treatment.id, data) - - assert result.supervised is True - assert result.registered_by is None - - @pytest.mark.asyncio async def test_patient_cannot_register_dose_on_inactive_treatment(create_tables, db_session): """Paciente não pode registrar dose em tratamento não-ativo.""" From 62f9099f56723c3952736871664895b23ed463ea Mon Sep 17 00:00:00 2001 From: rafaellucian0 Date: Mon, 8 Jun 2026 23:46:29 -0300 Subject: [PATCH 62/69] test: add integration test suites for dose registration flow and patient body map functionality --- backend/tests/integration/test_body_map.py | 4 ++-- backend/tests/integration/test_dose_flow.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/tests/integration/test_body_map.py b/backend/tests/integration/test_body_map.py index 6f3dad5..2bbfc40 100644 --- a/backend/tests/integration/test_body_map.py +++ b/backend/tests/integration/test_body_map.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -361,7 +361,7 @@ async def test_history_date_range_filter(async_client: AsyncClient, db_session: }, ) - today = date.today() + today = datetime.now(UTC).date() filtered = await async_client.get( "/v1/body-map/history", headers=headers, diff --git a/backend/tests/integration/test_dose_flow.py b/backend/tests/integration/test_dose_flow.py index 3595ba7..f75ba84 100644 --- a/backend/tests/integration/test_dose_flow.py +++ b/backend/tests/integration/test_dose_flow.py @@ -16,6 +16,7 @@ ValidationFailedError, ) from pequi.models.dose_log import AdherenceSnapshot +from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit from pequi.models.patient import PatientProfile from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus @@ -63,6 +64,19 @@ async def _create_patient(session, *, user: User, health_unit: HealthUnit) -> Pa return patient +async def _create_professional( + session, *, user: User, health_unit: HealthUnit +) -> HealthProfessional: + professional = HealthProfessional( + id=uuid4(), + user_id=user.id, + health_unit_id=health_unit.id, + ) + session.add(professional) + await session.flush() + return professional + + async def _create_treatment( session, *, From 0961e38fc31acbd761ac59b5cdda4fdb3a66b4bd Mon Sep 17 00:00:00 2001 From: Lucas Heron <111458155+LukeHer0@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:27:40 -0300 Subject: [PATCH 63/69] PEQ-157: calendar integration (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * calendar integration * fix: mapeamento de humor * fix: lint error * feat: integra consultas no calendário * fix: muda cor dos pontos no calendário --- backend/src/pequi/routers/calendar_router.py | 27 ++++ .../checkin/services/checkin.service.ts | 4 + frontend/src/app/features/home/home.html | 83 +++++++++-- frontend/src/app/features/home/home.ts | 132 +++++++++++++++++- .../home/services/calendar.service.ts | 17 +++ 5 files changed, 241 insertions(+), 22 deletions(-) create mode 100644 backend/src/pequi/routers/calendar_router.py create mode 100644 frontend/src/app/features/home/services/calendar.service.ts diff --git a/backend/src/pequi/routers/calendar_router.py b/backend/src/pequi/routers/calendar_router.py new file mode 100644 index 0000000..e119db7 --- /dev/null +++ b/backend/src/pequi/routers/calendar_router.py @@ -0,0 +1,27 @@ +from datetime import date + +from auth import get_current_user +from fastapi import APIRouter, Depends +from models.user import User + +router = APIRouter() + + +@router.get("/summary") +async def get_month_summary(year: int, month: int, current_user: User = Depends(get_current_user)): + + return { + "2026-05-24": ["checkin", "appointment"], + "2026-05-25": ["checkin"], + } + + +@router.get("/day-details") +async def get_day_details(target_date: date, current_user: User = Depends(get_current_user)): + return { + "date": target_date, + "events": [ + {"type": "checkin", "title": "Check-in matinal", "time": "08:00"}, + {"type": "appointment", "title": "Consulta com Dr. Silva", "time": "14:30"}, + ], + } diff --git a/frontend/src/app/features/checkin/services/checkin.service.ts b/frontend/src/app/features/checkin/services/checkin.service.ts index c501fed..a0512e3 100644 --- a/frontend/src/app/features/checkin/services/checkin.service.ts +++ b/frontend/src/app/features/checkin/services/checkin.service.ts @@ -95,6 +95,10 @@ export class CheckinService { return this.http.post(`${this.apiUrl}/v1/checkins`, payload); } + getCheckinHistory(): Observable { + return this.http.get(`${this.apiUrl}/v1/checkins`); + } + resolveSymptomIds(selectedNames: string[], catalog: SymptomResponse[]): string[] { if (!catalog.length) { return []; diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index 1fe17da..b492a89 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -44,9 +44,14 @@

    {{ currentMonthYear }} > {{ day.dayName }} {{ day.dayNumber }} -
    - @for (dot of day.dots; track $index) { -
    +
    + @for (dotType of day.dots; track $index) { + + }
    @@ -65,23 +70,37 @@

    {{ currentMonthYear }}
    Sáb

    -
    +
    @for (day of calendarMonth; track $index) { @if (day) { -
    + - {{ day.dayNumber }} -
    - @for (dot of day.dots; track $index) { -
    - } -
    + {{ day.dayNumber }} +
    + +
    + @for (dotType of day.dots; track $index) { + + + }
    + } @else { -
    +
    } }
    @@ -89,6 +108,40 @@

    {{ currentMonthYear }} } +
    +

    + Registros do dia {{ selectedDate | date:'dd/MM' }} +

    + + @if (selectedDayEvents().length > 0) { +
    + @for (event of selectedDayEvents(); track event.id) { +
    + +
    + +
    + +
    +

    {{ event.title }}

    +

    + {{ event.description }} +

    + + + {{ event.time | date:'HH:mm' }} + +
    +
    + } +
    + } @else { +
    +

    Nenhum registro encontrado para este dia.

    +
    + } +
    +
    diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index dc3817c..3148923 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -16,6 +16,8 @@ import { formatAppointmentDatePt, resolveNextAppointment, } from '../appointments/utils/next-appointment.utils'; +import { CheckinService } from '../checkin/services/checkin.service'; +import { HealthAppointment } from '../appointments/models/health-appointment.models'; interface QuickAction { title: string; @@ -29,7 +31,7 @@ interface CalendarDay { dateObj: Date; dayName: string; dayNumber: number; - dots: number[]; + dots: string[]; } interface Article { @@ -58,6 +60,7 @@ interface HomeHighlightCard { export class HomeComponent implements OnInit, AfterViewInit { private readonly router = inject(Router); private readonly appointmentService = inject(HealthAppointmentService); + private readonly checkinService = inject(CheckinService); readonly ImagePlus = ImagePlus; readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; @@ -65,6 +68,18 @@ export class HomeComponent implements OnInit, AfterViewInit { readonly Pill = Pill; readonly ChevronLeft = ChevronLeft; readonly ChevronRight = ChevronRight; + readonly moodMap: Record = { + 'great': 'Ótimo', + 'good': 'Muito Bem', + 'ok': 'Normal', + 'bad': 'Ruim', + 'terrible': 'Péssimo' + }; + + translateMood(mood: string): string { + if (!mood) return 'Não registrado'; + return this.moodMap[mood.toLowerCase()] || mood; + } @ViewChild('daysRow') daysRow!: ElementRef; @@ -74,6 +89,11 @@ export class HomeComponent implements OnInit, AfterViewInit { calendarMonth: (CalendarDay | null)[] = []; selectedDate: Date = new Date(); + monthDotsMap = signal>({}); + allCheckins = signal([]); + allAppointments = signal([]); + selectedDayEvents = signal([]); + readonly medicationSummaryCard: HomeHighlightCard = { value: '2/4', title: 'Medicações tomadas', @@ -175,6 +195,13 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.updateMonthYearLabel(); + this.appointmentService.syncFromApi().subscribe({ + next: (appointments) => { + this.allAppointments.set(appointments); + this.rebuildDotsMap(); + } + }); + this.fetchMonthData(); } ngAfterViewInit(): void { @@ -189,14 +216,90 @@ export class HomeComponent implements OnInit, AfterViewInit { } } + fetchMonthData() { + this.checkinService.getCheckinHistory().subscribe({ + next: (response) => { + const checkinsList = Array.isArray(response) ? response : response.items || []; + this.allCheckins.set(checkinsList); + + this.rebuildDotsMap(); + }, + error: (err) => console.error('Erro ao buscar check-ins:', err) + }); + } + + rebuildDotsMap() { + const dotsMap: Record = {}; + + this.allCheckins().forEach((checkin: any) => { + const dateField = checkin.created_at || checkin.date; + if (dateField) { + const dateKey = dateField.split('T')[0]; + if (!dotsMap[dateKey]) dotsMap[dateKey] = []; + dotsMap[dateKey].push('checkin'); + } + }); + + this.allAppointments().forEach((apt: HealthAppointment) => { + const dateField = apt.appointmentDate; + if (dateField) { + const dateKey = dateField.split('T')[0]; + if (!dotsMap[dateKey]) dotsMap[dateKey] = []; + dotsMap[dateKey].push('appointment'); + } + }); + + this.monthDotsMap.set(dotsMap); + this.generateCurrentWeek(); + this.generateCurrentMonth(); + this.filterEventsForSelectedDate(); + } + + filterEventsForSelectedDate() { + const clickedDateStr = this.getLocalIsoDate(this.selectedDate); + const mergedEvents: any[] = []; + + this.allCheckins().forEach(checkin => { + const dateField = checkin.created_at || checkin.date; + if (dateField && dateField.split('T')[0] === clickedDateStr) { + mergedEvents.push({ + type: 'checkin', + id: checkin.id, + time: dateField, + title: 'Check-in de Saúde', + description: checkin.notes || 'Humor: ' + this.translateMood(checkin.mood), + icon: this.CirclePlus, + colorClass: 'text-[#0EA5E9] bg-[#E0F2FE] border-[#0EA5E9]' + }); + } + }); + + this.allAppointments().forEach(apt => { + const dateField = apt.appointmentDate; + if (dateField && dateField.split('T')[0] === clickedDateStr) { + mergedEvents.push({ + type: 'appointment', + id: apt.id, + time: apt.appointmentTime ? `${dateField}T${apt.appointmentTime}` : dateField, + title: apt.type === 'exame' ? 'Exame' : apt.type === 'retorno' ? 'Retorno' : 'Consulta', + description: `Local: ${apt.location || 'Não informado'} ${apt.professional ? '- ' + apt.professional : ''}`, + icon: this.Stethoscope, + colorClass: 'text-[#9333EA] bg-[#F3E8FF] border-[#9333EA]' + }); + } + }); + mergedEvents.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + this.selectedDayEvents.set(mergedEvents); + } + changeMonth(delta: number) { const newDate = new Date(this.selectedDate); newDate.setMonth(newDate.getMonth() + delta); this.selectedDate = newDate; this.updateMonthYearLabel(); - this.generateCurrentWeek(); - this.generateCurrentMonth(); + this.fetchMonthData(); } goToToday() { @@ -224,10 +327,15 @@ export class HomeComponent implements OnInit, AfterViewInit { }, 100); } + private getLocalIsoDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + generateCurrentWeek() { this.calendarWeek = []; - const currentDay = this.selectedDate.getDay(); - const startOfScroll = new Date(this.selectedDate); startOfScroll.setDate(this.selectedDate.getDate() - 10); @@ -237,11 +345,14 @@ export class HomeComponent implements OnInit, AfterViewInit { const dateObj = new Date(startOfScroll); dateObj.setDate(startOfScroll.getDate() + i); + const dateKey = this.getLocalIsoDate(dateObj); + const dotsForDay = this.monthDotsMap()[dateKey] || []; + this.calendarWeek.push({ dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: dateObj.getDate(), - dots: Array(Math.floor(Math.random() * 3)).fill(0), + dots: dotsForDay, }); } } @@ -261,11 +372,15 @@ export class HomeComponent implements OnInit, AfterViewInit { for (let i = 1; i <= lastDayOfMonth.getDate(); i++) { const dateObj = new Date(year, month, i); + + const dateKey = this.getLocalIsoDate(dateObj); + const dotsForDay = this.monthDotsMap()[dateKey] || []; + this.calendarMonth.push({ dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: i, - dots: Array(Math.floor(Math.random() * 3)).fill(0), + dots: dotsForDay, }); } } @@ -291,6 +406,9 @@ export class HomeComponent implements OnInit, AfterViewInit { selectDate(date: Date) { this.selectedDate = date; this.updateMonthYearLabel(); + this.generateCurrentWeek(); + this.centerActiveDay(); + this.filterEventsForSelectedDate(); } isSameDate(date1: Date, date2: Date): boolean { diff --git a/frontend/src/app/features/home/services/calendar.service.ts b/frontend/src/app/features/home/services/calendar.service.ts new file mode 100644 index 0000000..a084ac9 --- /dev/null +++ b/frontend/src/app/features/home/services/calendar.service.ts @@ -0,0 +1,17 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class CalendarService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8000/v1/calendar'; + + getMonthSummary(year: number, month: number): Observable> { + return this.http.get>(`${this.apiUrl}/summary?year=${year}&month=${month}`); + } + + getDayDetails(date: string): Observable { + return this.http.get(`${this.apiUrl}/day-details?target_date=${date}`); + } +} \ No newline at end of file From 6a9a54e4e8bf199d09fe08e76c4a1e60c406c353 Mon Sep 17 00:00:00 2001 From: rafaellucian0 Date: Tue, 9 Jun 2026 01:02:09 -0300 Subject: [PATCH 64/69] fix: implement body map management with repository, schemas, and persistence layer --- .../112_align_body_areas_with_frontend.py | 208 ++++++++++++++++++ backend/src/pequi/models/body_map.py | 12 + .../src/pequi/repositories/body_map_repo.py | 8 +- backend/src/pequi/schemas/body_map.py | 5 +- backend/tests/integration/test_body_map.py | 11 +- backend/tests/unit/test_body_map_schema.py | 21 +- 6 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/112_align_body_areas_with_frontend.py diff --git a/backend/alembic/versions/112_align_body_areas_with_frontend.py b/backend/alembic/versions/112_align_body_areas_with_frontend.py new file mode 100644 index 0000000..85d4e87 --- /dev/null +++ b/backend/alembic/versions/112_align_body_areas_with_frontend.py @@ -0,0 +1,208 @@ +"""align body area catalog with frontend + +Revision ID: 112_align_body_areas_with_frontend +Revises: 111_create_journey_events +Create Date: 2026-06-09 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision: str = "112_align_body_areas_with_frontend" +down_revision: str | None = "111_create_journey_events" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +BODY_AREAS = ( + ("d7246d41-427a-4acf-b7f1-19c87e045a23", "face", "Face", "center", "head", 50, 10, "front"), + ("e4718197-848b-488c-a9e6-29ce3c978526", "neck", "Pescoço", "center", "head", 50, 17, "front"), + ( + "7a9d2743-a641-46f2-8206-a6bc967652e0", + "shoulders", + "Ombros", + "center", + "upper_limb", + 25, + 25, + "front", + ), + ( + "57d0040e-9cb5-4930-8d68-e5db16409026", + "arms", + "Braços", + "center", + "upper_limb", + 20, + 50, + "front", + ), + ( + "d0427a6e-14da-4f46-a2f5-6df4581e0b3b", + "hands", + "Mãos", + "center", + "upper_limb", + 15, + 75, + "front", + ), + ( + "64d0af93-f2d5-484f-a1a6-53d8f3778cad", + "abdomen", + "Abdômen", + "center", + "trunk", + 50, + 35, + "front", + ), + ("93d7f00b-cde9-4e2d-a84d-f6618e3dafaf", "hip", "Quadril", "center", "trunk", 50, 50, "front"), + ( + "2c242eb6-0c2f-43ee-a625-7aed0593c953", + "legs", + "Pernas", + "center", + "lower_limb", + 35, + 65, + "front", + ), + ( + "3ae541eb-62b2-4298-aa6f-b95a785a35a9", + "knees", + "Joelhos", + "center", + "lower_limb", + 35, + 80, + "front", + ), + ( + "b0d125a5-366f-43b4-b17c-d0b61743ed69", + "feet", + "Pés", + "center", + "lower_limb", + 35, + 95, + "front", + ), + ( + "5c19647e-df64-4bc1-893a-c8a077612631", + "scalp", + "Couro cabeludo", + "center", + "head", + 50, + 8, + "back", + ), + ("49201184-5df7-4f50-a3de-d28258586138", "nape", "Nuca", "center", "head", 50, 17, "back"), + ("04947b01-d28b-4db1-993a-3bb18b68ecac", "back", "Costas", "center", "trunk", 50, 35, "back"), + ( + "e224e751-dff5-4215-b165-d9a2d03c4431", + "buttocks", + "Glúteos", + "center", + "trunk", + 50, + 52, + "back", + ), + ( + "43265efe-892f-45f6-afb4-639373512cfb", + "posterior_thighs", + "Posterior das coxas", + "center", + "lower_limb", + 35, + 65, + "back", + ), + ( + "3d0353f1-5c81-4c17-9d22-b900f67d141e", + "calves", + "Panturrilhas", + "center", + "lower_limb", + 35, + 85, + "back", + ), +) + + +def upgrade() -> None: + body_view_enum = sa.Enum("front", "back", name="body_view_enum") + body_view_enum.create(op.get_bind(), checkfirst=True) + + op.add_column("body_areas", sa.Column("x", sa.SmallInteger(), nullable=True)) + op.add_column("body_areas", sa.Column("y", sa.SmallInteger(), nullable=True)) + op.add_column("body_areas", sa.Column("view", body_view_enum, nullable=True)) + op.add_column( + "body_areas", + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + ) + + op.execute("UPDATE body_areas SET x = 50, y = 50, view = 'front', is_active = false") + op.alter_column("body_areas", "x", nullable=False) + op.alter_column("body_areas", "y", nullable=False) + op.alter_column("body_areas", "view", nullable=False) + op.create_check_constraint("body_areas_x_range", "body_areas", "x >= 0 AND x <= 100") + op.create_check_constraint("body_areas_y_range", "body_areas", "y >= 0 AND y <= 100") + + body_areas = sa.table( + "body_areas", + sa.column("id", UUID(as_uuid=True)), + sa.column("code", sa.Text()), + sa.column("label", sa.Text()), + sa.column("side", sa.Enum(name="body_side_enum")), + sa.column("system_part", sa.Enum(name="body_system_part_enum")), + sa.column("x", sa.SmallInteger()), + sa.column("y", sa.SmallInteger()), + sa.column("view", sa.Enum(name="body_view_enum")), + sa.column("is_active", sa.Boolean()), + ) + op.bulk_insert( + body_areas, + [ + { + "id": area_id, + "code": code, + "label": label, + "side": side, + "system_part": system_part, + "x": x, + "y": y, + "view": view, + "is_active": True, + } + for area_id, code, label, side, system_part, x, y, view in BODY_AREAS + if code != "abdomen" + ], + ) + op.execute( + """ + UPDATE body_areas + SET label = 'Abdômen', side = 'center', system_part = 'trunk', + x = 50, y = 35, view = 'front', is_active = true + WHERE code = 'abdomen' + """ + ) + + +def downgrade() -> None: + codes = ", ".join(f"'{area[1]}'" for area in BODY_AREAS if area[1] != "abdomen") + op.execute(f"DELETE FROM body_areas WHERE code IN ({codes})") + op.execute("UPDATE body_areas SET is_active = true WHERE code = 'abdomen'") + op.drop_constraint("body_areas_y_range", "body_areas", type_="check") + op.drop_constraint("body_areas_x_range", "body_areas", type_="check") + op.drop_column("body_areas", "is_active") + op.drop_column("body_areas", "view") + op.drop_column("body_areas", "y") + op.drop_column("body_areas", "x") + sa.Enum(name="body_view_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/src/pequi/models/body_map.py b/backend/src/pequi/models/body_map.py index 7555238..1709d14 100644 --- a/backend/src/pequi/models/body_map.py +++ b/backend/src/pequi/models/body_map.py @@ -2,6 +2,7 @@ from enum import StrEnum from sqlalchemy import ( + Boolean, CheckConstraint, Column, DateTime, @@ -33,6 +34,11 @@ class BodySystemPart(StrEnum): lower_limb = "lower_limb" +class BodyView(StrEnum): + front = "front" + back = "back" + + class BodyFindingType(StrEnum): lesion = "lesion" hypoesthesia = "hypoesthesia" @@ -44,6 +50,8 @@ class BodyFindingType(StrEnum): class BodyArea(Base): __tablename__ = "body_areas" __table_args__ = ( + CheckConstraint("x >= 0 AND x <= 100", name="body_areas_x_range"), + CheckConstraint("y >= 0 AND y <= 100", name="body_areas_y_range"), Index("ix_body_areas_system_part", "system_part"), Index("ix_body_areas_label", "label"), ) @@ -59,6 +67,10 @@ class BodyArea(Base): Enum(BodySystemPart, name="body_system_part_enum"), nullable=False, ) + x = Column(SmallInteger, nullable=False, default=50) + y = Column(SmallInteger, nullable=False, default=50) + view = Column(Enum(BodyView, name="body_view_enum"), nullable=False, default=BodyView.front) + is_active = Column(Boolean, nullable=False, default=True, server_default=text("true")) class BodyMapEntry(Base): diff --git a/backend/src/pequi/repositories/body_map_repo.py b/backend/src/pequi/repositories/body_map_repo.py index bf637cf..864add5 100644 --- a/backend/src/pequi/repositories/body_map_repo.py +++ b/backend/src/pequi/repositories/body_map_repo.py @@ -13,14 +13,18 @@ def __init__(self, session: AsyncSession) -> None: self._session = session async def list_body_areas(self) -> list[BodyArea]: - stmt = select(BodyArea).order_by(BodyArea.system_part, BodyArea.label) + stmt = ( + select(BodyArea) + .where(BodyArea.is_active.is_(True)) + .order_by(BodyArea.system_part, BodyArea.label) + ) result = await self._session.execute(stmt) return list(result.scalars().all()) async def get_body_areas_by_ids(self, ids: Sequence[UUID]) -> list[BodyArea]: if not ids: return [] - stmt = select(BodyArea).where(BodyArea.id.in_(ids)) + stmt = select(BodyArea).where(BodyArea.id.in_(ids), BodyArea.is_active.is_(True)) result = await self._session.execute(stmt) return list(result.scalars().all()) diff --git a/backend/src/pequi/schemas/body_map.py b/backend/src/pequi/schemas/body_map.py index 603fd7c..326b435 100644 --- a/backend/src/pequi/schemas/body_map.py +++ b/backend/src/pequi/schemas/body_map.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart +from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart, BodyView class BodyAreaResponse(BaseModel): @@ -12,6 +12,9 @@ class BodyAreaResponse(BaseModel): label: str side: BodySide system_part: BodySystemPart + x: int = Field(ge=0, le=100) + y: int = Field(ge=0, le=100) + view: BodyView model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/integration/test_body_map.py b/backend/tests/integration/test_body_map.py index 2bbfc40..f986dd3 100644 --- a/backend/tests/integration/test_body_map.py +++ b/backend/tests/integration/test_body_map.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from pequi.core.auth import create_access_token -from pequi.models.body_map import BodyArea, BodySide, BodySystemPart +from pequi.models.body_map import BodyArea, BodySide, BodySystemPart, BodyView from pequi.models.symptom import Symptom, SymptomCategory from tests.integration.test_dose_flow import ( _create_health_unit, @@ -30,6 +30,9 @@ async def _create_body_area( label: str, side: BodySide, system_part: BodySystemPart, + x: int = 50, + y: int = 50, + view: BodyView = BodyView.front, ) -> BodyArea: area = BodyArea( id=uuid4(), @@ -37,6 +40,9 @@ async def _create_body_area( label=label, side=side, system_part=system_part, + x=x, + y=y, + view=view, ) session.add(area) await session.flush() @@ -80,6 +86,9 @@ async def test_body_areas_and_body_map_flow(async_client: AsyncClient, db_sessio list_areas = await async_client.get("/v1/body-areas", headers=headers) assert list_areas.status_code == 200 assert len(list_areas.json()) >= 2 + assert list_areas.json()[0]["view"] in {"front", "back"} + assert 0 <= list_areas.json()[0]["x"] <= 100 + assert 0 <= list_areas.json()[0]["y"] <= 100 update = await async_client.put( "/v1/body-map", diff --git a/backend/tests/unit/test_body_map_schema.py b/backend/tests/unit/test_body_map_schema.py index ff01249..df0720e 100644 --- a/backend/tests/unit/test_body_map_schema.py +++ b/backend/tests/unit/test_body_map_schema.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from pequi.schemas.body_map import BodyMapUpdateRequest +from pequi.schemas.body_map import BodyAreaResponse, BodyMapUpdateRequest def test_intensity_must_be_between_0_and_3(): @@ -50,3 +50,22 @@ def test_valid_payload_is_accepted(): assert payload.entries[0].intensity == 3 assert payload.entries[0].finding_type.value == "lesion" + + +def test_body_area_response_includes_display_position_and_view(): + area = BodyAreaResponse.model_validate( + { + "id": "5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26", + "code": "face", + "label": "Face", + "side": "center", + "system_part": "head", + "x": 50, + "y": 10, + "view": "front", + } + ) + + assert area.x == 50 + assert area.y == 10 + assert area.view.value == "front" From 3aa28c758bb23f16571ff2f830fa4fc24e7eb99e Mon Sep 17 00:00:00 2001 From: Matheus Ryan Date: Tue, 9 Jun 2026 01:31:06 -0300 Subject: [PATCH 65/69] PEQ-168: update revision identifier for body areas alignment migration --- backend/alembic/versions/112_align_body_areas_with_frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/alembic/versions/112_align_body_areas_with_frontend.py b/backend/alembic/versions/112_align_body_areas_with_frontend.py index 85d4e87..6d903e5 100644 --- a/backend/alembic/versions/112_align_body_areas_with_frontend.py +++ b/backend/alembic/versions/112_align_body_areas_with_frontend.py @@ -11,7 +11,7 @@ from alembic import op from sqlalchemy.dialects.postgresql import UUID -revision: str = "112_align_body_areas_with_frontend" +revision: str = "112_align_body_areas" down_revision: str | None = "111_create_journey_events" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From c79b474fecb26484cf2c9da3d27d2a1c7c6e9d6f Mon Sep 17 00:00:00 2001 From: Sarah Domingos <92494941+sarahdomingos@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:03:03 -0300 Subject: [PATCH 66/69] [PEQ-47-145]: Implementar tela de jornada (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: front da tela de jornada finalizado * test: correção de testes com bugs * feat: journey funciona integrado, mas sem medication/alerts integrados * style: ajuste na ordem de prioridade de mês atual * feat: journey funciona com medicamentos + card medicamentos na home integrado * fix: lint errors * fix: try to retest * fix: ruff organize * fix: organize import * fix: apply alembic ruff suggestions --------- Co-authored-by: Lucas Heron --- ..._create_daily_medication_progress_table.py | 78 +++++ backend/bruno/ROUTES.md | 1 + backend/bruno/patient/get_journey.bru | 19 + backend/src/pequi/models/__init__.py | 2 + .../pequi/models/daily_medication_progress.py | 41 +++ .../src/pequi/repositories/checkin_repo.py | 22 +- .../daily_medication_progress_repo.py | 59 ++++ backend/src/pequi/routers/patient.py | 73 ++++ .../schemas/daily_medication_progress.py | 61 ++++ backend/src/pequi/schemas/patient_journey.py | 63 ++++ .../use_cases/get_daily_medication_summary.py | 52 +++ .../pequi/use_cases/get_patient_journey.py | 322 +++++++++++++++-- .../upsert_daily_medication_progress.py | 51 +++ frontend/src/app/features/home/home.html | 6 +- frontend/src/app/features/home/home.ts | 79 ++++- .../src/app/features/journey/journey.html | 298 +++++++++++++++- .../src/app/features/journey/journey.spec.ts | 212 ++++++++++- frontend/src/app/features/journey/journey.ts | 329 +++++++++++++++++- .../journey/services/journey-service.spec.ts | 241 +++++++++++++ .../journey/services/journey-service.ts | 184 ++++++++++ frontend/src/app/features/login/login.spec.ts | 46 ++- .../app/features/medication/medication.html | 2 +- .../features/medication/medication.spec.ts | 1 + .../src/app/features/medication/medication.ts | 37 +- .../daily-medication-progress.service.ts | 54 +++ .../daily-medication-progress.spec.ts | 16 + .../services/medication-data.service.ts | 3 +- .../app/features/register/register.spec.ts | 95 +++-- 28 files changed, 2354 insertions(+), 93 deletions(-) create mode 100644 backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py create mode 100644 backend/bruno/patient/get_journey.bru create mode 100644 backend/src/pequi/models/daily_medication_progress.py create mode 100644 backend/src/pequi/repositories/daily_medication_progress_repo.py create mode 100644 backend/src/pequi/schemas/daily_medication_progress.py create mode 100644 backend/src/pequi/schemas/patient_journey.py create mode 100644 backend/src/pequi/use_cases/get_daily_medication_summary.py create mode 100644 backend/src/pequi/use_cases/upsert_daily_medication_progress.py create mode 100644 frontend/src/app/features/journey/services/journey-service.spec.ts create mode 100644 frontend/src/app/features/journey/services/journey-service.ts create mode 100644 frontend/src/app/features/medication/services/daily-medication-progress.service.ts create mode 100644 frontend/src/app/features/medication/services/daily-medication-progress.spec.ts diff --git a/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py b/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py new file mode 100644 index 0000000..e323fb8 --- /dev/null +++ b/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py @@ -0,0 +1,78 @@ +"""create daily medication progress table + +Revision ID: aeb509f804c0 +Revises: 108_patient_health_appointments +Create Date: 2026-06-08 23:56:27.762692 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "aeb509f804c0" +down_revision: str | None = "108_patient_health_appointments" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "daily_medication_progress", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("progress_date", sa.Date(), nullable=False), + sa.Column("expected_count", sa.Integer(), nullable=False), + sa.Column("taken_count", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "patient_id", + "progress_date", + name="uq_daily_medication_progress_patient_date", + ), + ) + + op.create_index( + "ix_daily_medication_progress_patient_id", + "daily_medication_progress", + ["patient_id"], + unique=False, + ) + op.create_index( + "ix_daily_medication_progress_progress_date", + "daily_medication_progress", + ["progress_date"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_daily_medication_progress_progress_date", + table_name="daily_medication_progress", + ) + op.drop_index( + "ix_daily_medication_progress_patient_id", + table_name="daily_medication_progress", + ) + op.drop_table("daily_medication_progress") diff --git a/backend/bruno/ROUTES.md b/backend/bruno/ROUTES.md index 08de930..a702ace 100644 --- a/backend/bruno/ROUTES.md +++ b/backend/bruno/ROUTES.md @@ -52,6 +52,7 @@ Contrato HTTP da API v1. Fonte: `docs/milestones/M*.md`. | `PATCH` | `/v1/patients/me` | 20/min | patient | `patient/update_profile.bru` ✅ | | `GET` | `/v1/patients` | 100/min | professional, admin | `patient/list_patients.bru` | | `GET` | `/v1/patients/{id}` | 100/min | professional, admin | `patient/get_patient.bru` | +| `GET` | `/v1/patients/me/journey` | 100/min | patient | `patient/get_journey.bru` | --- diff --git a/backend/bruno/patient/get_journey.bru b/backend/bruno/patient/get_journey.bru new file mode 100644 index 0000000..b12fafc --- /dev/null +++ b/backend/bruno/patient/get_journey.bru @@ -0,0 +1,19 @@ +meta { + name: Get Journey + type: http + seq: 10 +} + +get { + url: {{baseUrl}}/v1/patients/me/journey + auth: bearer +} + +assert { + res.status: eq 200 + res.body.summary: isDefined + res.body.summary.classification: isDefined + res.body.summary.treatment_duration_months: isDefined + res.body.summary.progress_percent: isDefined + res.body.months: isDefined +} \ No newline at end of file diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 3818323..521b45b 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -10,6 +10,7 @@ CommunityPost, ) from pequi.models.consent import Consent +from pequi.models.daily_medication_progress import DailyMedicationProgress from pequi.models.data_deletion import DataDeletionRequest from pequi.models.dose_log import AdherenceSnapshot, DoseLog from pequi.models.health_appointment import PatientHealthAppointment @@ -50,4 +51,5 @@ "Treatment", "User", "WeeklySymptomSummary", + "DailyMedicationProgress", ] diff --git a/backend/src/pequi/models/daily_medication_progress.py b/backend/src/pequi/models/daily_medication_progress.py new file mode 100644 index 0000000..6da7f9c --- /dev/null +++ b/backend/src/pequi/models/daily_medication_progress.py @@ -0,0 +1,41 @@ +# pequi/models/daily_medication_progress.py +import uuid + +from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class DailyMedicationProgress(Base): + __tablename__ = "daily_medication_progress" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + progress_date = Column(Date, nullable=False) + expected_count = Column(Integer, nullable=False) + taken_count = Column(Integer, nullable=False) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + __table_args__ = ( + UniqueConstraint( + "patient_id", + "progress_date", + name="uq_daily_medication_progress_patient_date", + ), + ) diff --git a/backend/src/pequi/repositories/checkin_repo.py b/backend/src/pequi/repositories/checkin_repo.py index 4c3392d..ae9d589 100644 --- a/backend/src/pequi/repositories/checkin_repo.py +++ b/backend/src/pequi/repositories/checkin_repo.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload from pequi.models.checkin import Checkin, CheckinMood, checkin_symptoms -from pequi.schemas.checkin import CheckinCreate +from pequi.schemas.checkin import CheckinCreate, CheckinResponse class CheckinRepository: @@ -77,6 +77,26 @@ async def list_by_patient( result = await self._session.execute(stmt) return list(result.scalars().all()), total + async def list_history_by_patient_id( + self, + patient_id: UUID, + limit: int = 500, + offset: int = 0, + ) -> list[CheckinResponse]: + from pequi.schemas.checkin import checkin_to_response + + stmt = ( + select(Checkin) + .options(selectinload(Checkin.symptoms)) + .where(Checkin.patient_id == patient_id) + .order_by(Checkin.checked_in_at.asc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + rows = list(result.scalars().all()) + return [checkin_to_response(row) for row in rows] + async def get_recent_moods( self, patient_id: UUID, diff --git a/backend/src/pequi/repositories/daily_medication_progress_repo.py b/backend/src/pequi/repositories/daily_medication_progress_repo.py new file mode 100644 index 0000000..0d49d50 --- /dev/null +++ b/backend/src/pequi/repositories/daily_medication_progress_repo.py @@ -0,0 +1,59 @@ +from datetime import date +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.daily_medication_progress import DailyMedicationProgress + + +class DailyMedicationProgressRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_patient_and_date( + self, + patient_id: UUID, + progress_date: date, + ) -> DailyMedicationProgress | None: + stmt = select(DailyMedicationProgress).where( + DailyMedicationProgress.patient_id == patient_id, + DailyMedicationProgress.progress_date == progress_date, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient_id(self, patient_id: UUID) -> list[DailyMedicationProgress]: + stmt = ( + select(DailyMedicationProgress) + .where(DailyMedicationProgress.patient_id == patient_id) + .order_by(DailyMedicationProgress.progress_date.asc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def upsert( + self, + patient_id: UUID, + progress_date: date, + expected_count: int, + taken_count: int, + ) -> DailyMedicationProgress: + progress = await self.get_by_patient_and_date(patient_id, progress_date) + + if progress is None: + progress = DailyMedicationProgress( + patient_id=patient_id, + progress_date=progress_date, + expected_count=expected_count, + taken_count=taken_count, + ) + self._session.add(progress) + await self._session.flush() + return progress + + progress.expected_count = expected_count + progress.taken_count = taken_count + await self._session.flush() + await self._session.refresh(progress) + return progress diff --git a/backend/src/pequi/routers/patient.py b/backend/src/pequi/routers/patient.py index adc761d..8bf1a63 100644 --- a/backend/src/pequi/routers/patient.py +++ b/backend/src/pequi/routers/patient.py @@ -1,3 +1,4 @@ +from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request @@ -5,17 +6,27 @@ from pequi.core.dependencies import get_current_patient, get_db from pequi.core.rate_limit import limiter +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.daily_medication_progress import ( + DailyMedicationProgressResponse, + DailyMedicationProgressUpsert, + DailyMedicationSummaryResponse, +) from pequi.schemas.health_appointment import ( HealthAppointmentCreate, HealthAppointmentResponse, HealthAppointmentUpdate, ) from pequi.schemas.patient import PatientProfileRead, PatientProfileUpdate +from pequi.schemas.patient_journey import PatientJourneyResponse from pequi.schemas.patient_personal import ( PatientPersonalRecordRead, PatientPersonalRecordSave, @@ -26,6 +37,10 @@ PatientTreatmentRecordSave, ) from pequi.schemas.treatment import TreatmentResponse +from pequi.use_cases.get_daily_medication_summary import ( + GetDailyMedicationSummaryUseCase, +) +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase from pequi.use_cases.get_patient_profile import GetPatientProfileUseCase from pequi.use_cases.patient_health_appointment import ( CreatePatientHealthAppointmentUseCase, @@ -43,6 +58,9 @@ SavePatientTreatmentRecordUseCase, ) from pequi.use_cases.update_patient_profile import UpdatePatientProfileUseCase +from pequi.use_cases.upsert_daily_medication_progress import ( + UpsertDailyMedicationProgressUseCase, +) router = APIRouter() @@ -56,6 +74,18 @@ def _treatment_repos( ) +def _daily_medication_progress_repos( + session: AsyncSession, +) -> tuple[ + DailyMedicationProgressRepository, + PatientRepository, +]: + return ( + DailyMedicationProgressRepository(session), + PatientRepository(session), + ) + + @router.get("/me", response_model=PatientProfileRead) async def get_my_profile( user_id: UUID = Depends(get_current_patient), @@ -217,3 +247,46 @@ async def update_my_appointment( JourneyEventRepository(session), ) return await use_case.execute(user_id, appointment_id, body) + + +@router.get("/me/journey", response_model=PatientJourneyResponse) +@limiter.limit("100/minute") +async def get_my_journey( + request: Request, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> PatientJourneyResponse: + use_case = GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + HealthAppointmentRepository(session), + CheckinRepository(session), + DailyMedicationProgressRepository(session), + ) + return await use_case.execute(user_id) + + +@router.get("/me/daily-medication-progress", response_model=DailyMedicationSummaryResponse) +@limiter.limit("100/minute") +async def get_my_daily_medication_progress( + request: Request, + progress_date: date, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DailyMedicationSummaryResponse: + progress_repo, patient_repo = _daily_medication_progress_repos(session) + use_case = GetDailyMedicationSummaryUseCase(progress_repo, patient_repo) + return await use_case.execute(user_id, progress_date) + + +@router.put("/me/daily-medication-progress", response_model=DailyMedicationProgressResponse) +@limiter.limit("20/minute") +async def save_my_daily_medication_progress( + request: Request, + body: DailyMedicationProgressUpsert, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DailyMedicationProgressResponse: + progress_repo, patient_repo = _daily_medication_progress_repos(session) + use_case = UpsertDailyMedicationProgressUseCase(progress_repo, patient_repo) + return await use_case.execute(user_id, body) diff --git a/backend/src/pequi/schemas/daily_medication_progress.py b/backend/src/pequi/schemas/daily_medication_progress.py new file mode 100644 index 0000000..caeb7d3 --- /dev/null +++ b/backend/src/pequi/schemas/daily_medication_progress.py @@ -0,0 +1,61 @@ +# pequi/schemas/daily_medication_progress.py +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DailyMedicationProgressUpsert(BaseModel): + """Payload para salvar o progresso diário de medicações.""" + + model_config = ConfigDict(extra="forbid") + + progress_date: date + expected_count: int = Field( + ..., + ge=0, + description="Quantidade total de doses/checkboxes esperados no dia", + ) + taken_count: int = Field( + ..., + ge=0, + description="Quantidade de doses/checkboxes marcados como tomados no dia", + ) + + @model_validator(mode="after") + def validate_counts(self) -> "DailyMedicationProgressUpsert": + if self.taken_count > self.expected_count: + raise ValueError("taken_count não pode ser maior que expected_count.") + return self + + +class DailyMedicationProgressResponse(BaseModel): + id: UUID + patient_id: UUID + progress_date: date + expected_count: int + taken_count: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class DailyMedicationSummaryResponse(BaseModel): + progress_date: date + expected_count: int + taken_count: int + remaining_count: int + completed: bool + + +def daily_medication_progress_to_response(progress) -> DailyMedicationProgressResponse: + return DailyMedicationProgressResponse( + id=progress.id, + patient_id=progress.patient_id, + progress_date=progress.progress_date, + expected_count=progress.expected_count, + taken_count=progress.taken_count, + created_at=progress.created_at, + updated_at=progress.updated_at, + ) diff --git a/backend/src/pequi/schemas/patient_journey.py b/backend/src/pequi/schemas/patient_journey.py new file mode 100644 index 0000000..a4a29f3 --- /dev/null +++ b/backend/src/pequi/schemas/patient_journey.py @@ -0,0 +1,63 @@ +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class JourneySummary(BaseModel): + patient_id: UUID + user_id: UUID + display_name: str | None = None + classification: str | None = None + diagnosis_date: date | None = None + treatment_start_date: date | None = None + estimated_end_date: date | None = None + treatment_status: str | None = None + treatment_duration_months: int + total_days: int + elapsed_days: int + remaining_days: int + progress_percent: int + current_month: int + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMedicationSummary(BaseModel): + doses_taken: int = 0 + doses_expected: int = 0 + adherence_percent: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +class JourneyEvent(BaseModel): + id: str + type: str + date: datetime | date + title: str + description: str + status: str = "neutral" + metadata: dict[str, Any] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMonth(BaseModel): + month_index: int + label: str + start_date: date + end_date: date + status: str + medication_summary: JourneyMedicationSummary + events: list[JourneyEvent] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +class PatientJourneyResponse(BaseModel): + summary: JourneySummary + months: list[JourneyMonth] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/use_cases/get_daily_medication_summary.py b/backend/src/pequi/use_cases/get_daily_medication_summary.py new file mode 100644 index 0000000..9f0139f --- /dev/null +++ b/backend/src/pequi/use_cases/get_daily_medication_summary.py @@ -0,0 +1,52 @@ +# pequi/use_cases/get_daily_medication_summary.py +from datetime import date +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.daily_medication_progress import DailyMedicationSummaryResponse + + +class GetDailyMedicationSummaryUseCase: + def __init__( + self, + progress_repo: DailyMedicationProgressRepository, + patient_repo: PatientRepository, + ) -> None: + self._progress_repo = progress_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + progress_date: date, + ) -> DailyMedicationSummaryResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + progress = await self._progress_repo.get_by_patient_and_date( + patient.id, + progress_date, + ) + + if progress is None: + return DailyMedicationSummaryResponse( + progress_date=progress_date, + expected_count=0, + taken_count=0, + remaining_count=0, + completed=False, + ) + + return DailyMedicationSummaryResponse( + progress_date=progress.progress_date, + expected_count=progress.expected_count, + taken_count=progress.taken_count, + remaining_count=max(progress.expected_count - progress.taken_count, 0), + completed=progress.expected_count > 0 + and progress.taken_count == progress.expected_count, + ) diff --git a/backend/src/pequi/use_cases/get_patient_journey.py b/backend/src/pequi/use_cases/get_patient_journey.py index 47fb475..c87b94e 100644 --- a/backend/src/pequi/use_cases/get_patient_journey.py +++ b/backend/src/pequi/use_cases/get_patient_journey.py @@ -1,49 +1,321 @@ +from datetime import UTC, date, datetime, timedelta from uuid import UUID -from pequi.core.exceptions import NotFoundError -from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.daily_medication_progress_repo import DailyMedicationProgressRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository -from pequi.schemas.journey import JourneyResponse -from pequi.services.journey_service import JourneyService +from pequi.schemas.checkin import CheckinResponse +from pequi.schemas.patient_journey import ( + JourneyEvent, + JourneyMedicationSummary, + JourneyMonth, + JourneySummary, + PatientJourneyResponse, +) class GetPatientJourneyUseCase: - """Retorna a jornada de tratamento do paciente autenticado.""" - def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - dose_repo: DoseRepository, appointment_repo: HealthAppointmentRepository, - journey_event_repo: JourneyEventRepository, + checkin_repo: CheckinRepository, + daily_progress_repo: DailyMedicationProgressRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo - self._dose_repo = dose_repo self._appointment_repo = appointment_repo - self._journey_event_repo = journey_event_repo + self._checkin_repo = checkin_repo + self._daily_progress_repo = daily_progress_repo - async def execute(self, user_id: UUID) -> JourneyResponse: + async def execute(self, user_id: UUID) -> PatientJourneyResponse: patient = await self._patient_repo.get_or_create_by_user_id(user_id) treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) - if treatment is None: - raise NotFoundError("Treatment", "Nenhum tratamento ativo encontrado.") - - doses = await self._dose_repo.list_by_treatment(treatment.id) appointments = await self._appointment_repo.list_by_patient_id(patient.id) - snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) - journey_events = await self._journey_event_repo.list_for_treatment(patient.id, treatment.id) + checkins = await self._checkin_repo.list_history_by_patient_id( + patient.id, + limit=500, + offset=0, + ) + + treatment_start = self._resolve_treatment_start(patient, treatment) + classification = patient.classification + total_months = self._resolve_total_months(classification) + total_days = total_months * 30 + today = datetime.now(UTC).date() + + elapsed_days = 0 + remaining_days = total_days + progress_percent = 0 + current_month = 1 + estimated_end_date = None + treatment_status = None + + if treatment is not None: + treatment_status = ( + treatment.status.value + if hasattr(treatment.status, "value") + else str(treatment.status) + ) - return JourneyService.build_journey( + if treatment_start and total_days > 0: + elapsed_days = max(0, (today - treatment_start).days) + remaining_days = max(0, total_days - elapsed_days) + progress_percent = min(100, int((elapsed_days / total_days) * 100)) + current_month = min(total_months, max(1, (elapsed_days // 30) + 1)) + estimated_end_date = ( + treatment.expected_end + if treatment and treatment.expected_end is not None + else treatment_start + timedelta(days=total_days) + ) + + summary = JourneySummary( patient_id=patient.id, - patient=patient, - treatment=treatment, - doses=doses, - appointments=appointments, - journey_events=journey_events, - adherence_snapshot=snapshot, + user_id=patient.user_id, + display_name=self._resolve_display_name(patient), + classification=classification, + diagnosis_date=patient.diagnosis_date, + treatment_start_date=treatment_start, + estimated_end_date=estimated_end_date, + treatment_status=treatment_status, + treatment_duration_months=total_months, + total_days=total_days, + elapsed_days=elapsed_days, + remaining_days=remaining_days, + progress_percent=progress_percent, + current_month=current_month, + ) + + if not treatment_start or total_months == 0: + return PatientJourneyResponse(summary=summary, months=[]) + + daily_progress_logs = await self._daily_progress_repo.list_by_patient_id(patient.id) + + months: list[JourneyMonth] = [] + for month_index in range(1, total_months + 1): + month_start = treatment_start + timedelta(days=(month_index - 1) * 30) + month_end = month_start + timedelta(days=29) + + month_appointments = [ + item for item in appointments if month_start <= item.appointment_date <= month_end + ] + + month_checkins = [ + item for item in checkins if month_start <= item.checked_in_at.date() <= month_end + ] + + month_progress_logs = [ + item + for item in daily_progress_logs + if month_start <= item.progress_date <= month_end + ] + + if month_index < current_month: + month_status = "completed" + elif month_index == current_month: + month_status = "current" + else: + month_status = "upcoming" + + medication_summary = self._build_medication_summary(month_progress_logs) + + events = self._build_month_events( + month_index=month_index, + treatment_start=treatment_start, + month_end=month_end, + month_appointments=month_appointments, + month_checkins=month_checkins, + medication_summary=medication_summary, + ) + + months.append( + JourneyMonth( + month_index=month_index, + label=f"Mês {month_index}", + start_date=month_start, + end_date=month_end, + status=month_status, + medication_summary=medication_summary, + events=events, + ) + ) + + return PatientJourneyResponse(summary=summary, months=months) + + def _resolve_total_months(self, classification: str | None) -> int: + if classification == "PB": + return 6 + if classification == "MB": + return 12 + return 0 + + def _resolve_treatment_start(self, patient, treatment) -> date | None: + if treatment is not None and treatment.start_date is not None: + return treatment.start_date + + record = patient.treatment_record if isinstance(patient.treatment_record, dict) else {} + start = record.get("treatment_start_date") + if start: + return date.fromisoformat(start) + + return None + + def _resolve_display_name(self, patient) -> str | None: + personal = patient.personal_record if isinstance(patient.personal_record, dict) else {} + return personal.get("social_name") or None + + def _build_medication_summary(self, month_progress_logs) -> JourneyMedicationSummary: + total_days_in_month_window = 30 + completed_days = 0 + + for progress in month_progress_logs: + if progress.expected_count > 0 and progress.taken_count == progress.expected_count: + completed_days += 1 + + adherence_percent = int((completed_days / total_days_in_month_window) * 100) + + return JourneyMedicationSummary( + doses_taken=completed_days, + doses_expected=total_days_in_month_window, + adherence_percent=adherence_percent, + ) + + def _build_month_events( + self, + month_index: int, + treatment_start: date, + month_end: date, + month_appointments: list, + month_checkins: list[CheckinResponse], + medication_summary: JourneyMedicationSummary, + ) -> list[JourneyEvent]: + events: list[JourneyEvent] = [] + + if month_index == 1: + events.append( + JourneyEvent( + id=f"treatment-start-{month_index}", + type="treatment-start", + date=treatment_start, + title="Início do tratamento", + description="Seu tratamento foi iniciado e sua jornada começou.", + status="positive", + ) + ) + + for appointment in month_appointments: + title = "Consulta realizada" if appointment.performed else "Consulta agendada" + description = f"{appointment.appointment_type} em {appointment.location}" + + if appointment.professional: + description += f" com {appointment.professional}" + + events.append( + JourneyEvent( + id=str(appointment.id), + type="appointment", + date=appointment.appointment_date, + title=title, + description=description, + status="neutral", + metadata={ + "appointment_type": appointment.appointment_type, + "location": appointment.location, + "professional": appointment.professional, + "performed": appointment.performed, + "status": appointment.status, + "follow_up": appointment.follow_up, + }, + ) + ) + + events.append( + JourneyEvent( + id=f"medication-summary-{month_index}", + type="medication-summary", + date=month_end, + title=f"Resumo de medicação do mês {month_index}", + description=( + f"Você completou {medication_summary.doses_taken} de 30 dias do mês " + f"tomando todas as medicações esperadas." + ), + status="positive" if medication_summary.adherence_percent >= 80 else "neutral", + metadata={ + "dosesTaken": medication_summary.doses_taken, + "dosesExpected": medication_summary.doses_expected, + "adherencePercent": medication_summary.adherence_percent, + }, + ) ) + + trend = self._infer_checkin_trend(month_checkins) + + if trend == "improved": + events.append( + JourneyEvent( + id=f"clinical-improved-{month_index}", + type="clinical-update", + date=month_end, + title="Melhora percebida neste mês", + description="Os registros indicam melhora da intensidade dos sintomas neste período.", # noqa: E501 + status="positive", + ) + ) + events.append( + JourneyEvent( + id=f"support-message-{month_index}", + type="motivational-message", + date=month_end, + title="Continue seguindo seu tratamento", + description="Manter a regularidade ajuda a sustentar sua melhora.", + status="positive", + ) + ) + + elif trend == "worsened": + events.append( + JourneyEvent( + id=f"clinical-worsened-{month_index}", + type="clinical-update", + date=month_end, + title="Atenção aos sintomas", + description="Os registros indicam piora da intensidade dos sintomas neste período.", # noqa: E501 + status="attention", + ) + ) + events.append( + JourneyEvent( + id=f"alert-message-{month_index}", + type="motivational-message", + date=month_end, + title="Siga monitorando sua evolução", + description="Continue registrando seus sintomas e compartilhe essas informações na próxima consulta.", # noqa: E501 + status="attention", + ) + ) + + events.sort( + key=lambda item: ( + item.date + if isinstance(item.date, datetime) + else datetime.combine(item.date, datetime.min.time()) + ) + ) + return events + + def _infer_checkin_trend(self, month_checkins: list[CheckinResponse]) -> str | None: + if len(month_checkins) < 2: + return None + + ordered = sorted(month_checkins, key=lambda item: item.checked_in_at) + first = ordered[0].symptom_intensity + last = ordered[-1].symptom_intensity + + if last <= first - 2: + return "improved" + if last >= first + 2: + return "worsened" + return None diff --git a/backend/src/pequi/use_cases/upsert_daily_medication_progress.py b/backend/src/pequi/use_cases/upsert_daily_medication_progress.py new file mode 100644 index 0000000..bdf308b --- /dev/null +++ b/backend/src/pequi/use_cases/upsert_daily_medication_progress.py @@ -0,0 +1,51 @@ +# pequi/use_cases/upsert_daily_medication_progress.py +from uuid import UUID + +from sqlalchemy.exc import IntegrityError + +from pequi.core.exceptions import NotFoundError, ValidationFailedError +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.daily_medication_progress import ( + DailyMedicationProgressResponse, + DailyMedicationProgressUpsert, + daily_medication_progress_to_response, +) + + +class UpsertDailyMedicationProgressUseCase: + def __init__( + self, + progress_repo: DailyMedicationProgressRepository, + patient_repo: PatientRepository, + ) -> None: + self._progress_repo = progress_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + data: DailyMedicationProgressUpsert, + ) -> DailyMedicationProgressResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + if data.taken_count > data.expected_count: + raise ValidationFailedError("taken_count não pode ser maior que expected_count.") + + try: + progress = await self._progress_repo.upsert( + patient_id=patient.id, + progress_date=data.progress_date, + expected_count=data.expected_count, + taken_count=data.taken_count, + ) + except IntegrityError as exc: + raise ValidationFailedError( + "Não foi possível salvar o progresso diário de medicação." + ) from exc + + return daily_medication_progress_to_response(progress) diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index b492a89..56809ea 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -144,9 +144,9 @@

    {{ event.title }}

    -
    - {{ medicationSummaryCard.value }} - {{ medicationSummaryCard.title }} +
    + {{ medicationSummaryCard().value }} + {{ medicationSummaryCard().title }}
    ([]); selectedDayEvents = signal([]); - readonly medicationSummaryCard: HomeHighlightCard = { - value: '2/4', - title: 'Medicações tomadas', - backgroundClass: 'summary-card--purple', - }; + readonly medicationSummary = signal(null); + + readonly medicationSummaryCard = computed(() => { + const summary = this.medicationSummary(); + + if (!summary || summary.expected_count === 0) { + return { + value: '0/0', + title: 'Medicações tomadas', + subtitle: 'Nenhuma dose esperada para hoje', + backgroundClass: 'summary-card--purple', + }; + } + + return { + value: `${summary.taken_count}/${summary.expected_count}`, + title: 'Medicações tomadas', + subtitle: summary.completed ? 'Todas as doses do dia foram marcadas' : 'Progresso de hoje', + backgroundClass: 'summary-card--purple', + }; + }); readonly nextAppointmentCard = computed(() => { const next = resolveNextAppointment(this.appointmentService.appointments()); @@ -131,7 +162,7 @@ export class HomeComponent implements OnInit, AfterViewInit { colorClass: 'blue-icon', path: '/checkin', }, - { + { title: 'Registrar medicamentos', description: 'Veja quais remédios tomar hoje', icon: this.Pill, @@ -195,6 +226,7 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.updateMonthYearLabel(); + this.loadDailyMedicationSummary(); this.appointmentService.syncFromApi().subscribe({ next: (appointments) => { this.allAppointments.set(appointments); @@ -209,7 +241,7 @@ export class HomeComponent implements OnInit, AfterViewInit { } toggleCalendar() { - this.isExpanded.update(val => !val); + this.isExpanded.update((val) => !val); if (!this.isExpanded()) { this.centerActiveDay(); @@ -297,7 +329,7 @@ export class HomeComponent implements OnInit, AfterViewInit { const newDate = new Date(this.selectedDate); newDate.setMonth(newDate.getMonth() + delta); this.selectedDate = newDate; - + this.updateMonthYearLabel(); this.fetchMonthData(); } @@ -308,6 +340,7 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.centerActiveDay(); + this.loadDailyMedicationSummary(); } centerActiveDay() { @@ -318,10 +351,10 @@ export class HomeComponent implements OnInit, AfterViewInit { const activeCard = container.querySelector('.day-card.active') as HTMLElement; if (activeCard) { - activeCard.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center' + activeCard.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', }); } }, 100); @@ -418,4 +451,24 @@ export class HomeComponent implements OnInit, AfterViewInit { date1.getFullYear() === date2.getFullYear() ); } + + private loadDailyMedicationSummary(): void { + this.dailyMedicationProgressService.getSummary(this.getTodayDate()).subscribe({ + next: (summary) => { + this.medicationSummary.set(summary); + }, + error: () => { + this.medicationSummary.set(null); + }, + }); + } + + private getTodayDate(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } } diff --git a/frontend/src/app/features/journey/journey.html b/frontend/src/app/features/journey/journey.html index ae50a89..d5b3785 100644 --- a/frontend/src/app/features/journey/journey.html +++ b/frontend/src/app/features/journey/journey.html @@ -1 +1,297 @@ -

    journey works!

    +
    +
    +
    +

    + Sua jornada +

    + +

    + Acompanhe sua evolução no tratamento mês a mês e registre cada etapa + importante da sua jornada. +

    + +
    + + {{ leprosyTypeLabel() }} + + + + {{ treatmentEstimateText() }} + +
    +
    + + @if (shouldShowJourneySetupState()) { + + } @else { +
    +
    +
    +

    + {{ progressHeadline() }} +

    + +

    + {{ progressSupportText() }} +

    +
    + +
    +
    + {{ progressPercent() }}% +
    +
    + +
    + {{ remainingText() }} +
    +
    +
    + +
    +
    + + +
    +

    + Acompanhamento atual +

    + +

    + Você está no {{ currentMonth() }}º mês do tratamento +

    + +

    + Seu tipo de hanseníase e a estimativa total de tratamento orientam os + marcos desta jornada para tornar o progresso mais claro. +

    +
    +
    +
    + +
    + @for (month of displayMonths(); track month.monthIndex) { +
    + + +
    + {{ month.monthIndex }} +
    + +
    + + + @if (month.expanded && !month.locked) { +
    + @if (month.events.length) { +
    + @for (event of month.events; track event.id) { +
    +
    +
    +

    + {{ event.date | date:'dd MMM yyyy' }} +

    + +

    + {{ event.title }} +

    +
    + + + {{ getEventTypeLabel(event.type) }} + +
    + +

    + {{ event.description }} +

    + + @if (event.type === 'medication-summary' && event.metadata) { +
    + Dias completos de medicação no ciclo: + {{ event.metadata.dosesTaken ?? 0 }} + @if (event.metadata.dosesExpected) { + + / {{ event.metadata.dosesExpected }} + + } +
    + } + @if (event.type === 'appointment' && event.metadata?.consultationLocation) { +
    + Local da consulta: + {{ event.metadata?.consultationLocation }} +
    + } +
    + } +
    + } @else { +
    + Nenhum registro foi adicionado neste mês até agora. +
    + } +
    + } +
    +
    + } +
    + } +
    +
    \ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.spec.ts b/frontend/src/app/features/journey/journey.spec.ts index 3ffefdc..e09d356 100644 --- a/frontend/src/app/features/journey/journey.spec.ts +++ b/frontend/src/app/features/journey/journey.spec.ts @@ -1,22 +1,222 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Journey } from './journey'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Journey, JourneyEvent } from './journey'; describe('Journey', () => { - let component: Journey; let fixture: ComponentFixture; + let component: Journey; + + const mockEvents: JourneyEvent[] = [ + { + id: 'appointment-m1-001', + type: 'appointment', + title: 'Primeira consulta após início do tratamento', + description: 'Consulta inicial registrada.', + date: '2026-04-10', + status: 'neutral', + metadata: { + consultationLocation: 'UBS Benedito Bentes', + }, + }, + { + id: 'clinical-update-m1-001', + type: 'clinical-update', + title: 'Piora registrada em lesão cutânea', + description: 'Paciente relatou piora.', + date: '2026-04-14', + status: 'attention', + metadata: { + symptomTrend: 'worsened', + }, + }, + { + id: 'medication-summary-m1-001', + type: 'medication-summary', + title: 'Resumo de medicação do mês 1', + description: 'Resumo do primeiro mês.', + date: '2026-05-04', + status: 'positive', + metadata: { + dosesTaken: 28, + dosesExpected: 30, + }, + }, + { + id: 'appointment-m2-001', + type: 'appointment', + title: 'Consulta de acompanhamento do segundo mês', + description: 'Consulta do mês 2.', + date: '2026-05-12', + status: 'neutral', + metadata: { + consultationLocation: 'Ambulatório de Dermatologia Municipal', + }, + }, + { + id: 'clinical-update-m2-001', + type: 'clinical-update', + title: 'Melhora percebida pelo paciente', + description: 'Paciente relatou melhora.', + date: '2026-05-18', + status: 'positive', + metadata: { + symptomTrend: 'improved', + }, + }, + ]; beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T12:00:00')); + await TestBed.configureTestingModule({ imports: [Journey], }).compileComponents(); fixture = TestBed.createComponent(Journey); component = fixture.componentInstance; - await fixture.whenStable(); + + fixture.componentRef.setInput('patientName', 'José da Silva'); + fixture.componentRef.setInput('leprosyType', 'PB'); + fixture.componentRef.setInput('appStartDate', '2026-04-03'); + fixture.componentRef.setInput('treatmentStartDate', '2026-04-05'); + fixture.componentRef.setInput('events', mockEvents); + + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + TestBed.resetTestingModule(); }); it('should create', () => { expect(component).toBeTruthy(); }); -}); + + it('should calculate PB treatment with 6 months and 180 days', () => { + expect(component.totalMonths()).toBe(6); + expect(component.totalDays()).toBe(180); + }); + + it('should calculate progress based on elapsed days', () => { + expect(component.elapsedDays()).toBe(45); + expect(component.currentMonth()).toBe(2); + expect(component.progressPercent()).toBe(25); + }); + + it('should show PB label and 6 month estimate', () => { + expect(component.leprosyTypeLabel()).toContain('paucibacilar'); + expect(component.treatmentEstimateText()).toContain('6 meses'); + }); + + it('should build 6 months for PB journey', () => { + expect(component.months()).toHaveLength(6); + expect(component.months()[0].label).toBe('Mês 1'); + expect(component.months()[5].label).toBe('Mês 6'); + }); + + it('should mark month 2 as current', () => { + const month2 = component.months().find((month) => month.monthIndex === 2); + const month1 = component.months().find((month) => month.monthIndex === 1); + const month3 = component.months().find((month) => month.monthIndex === 3); + + expect(month1?.completed).toBe(true); + expect(month2?.current).toBe(true); + expect(month3?.locked).toBe(true); + }); + + it('should include generated app start and treatment start events in month 1', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + + expect(month1).toBeTruthy(); + expect( + month1?.events.some((event) => event.type === 'app-start') + ).toBe(true); + expect( + month1?.events.some((event) => event.type === 'treatment-start') + ).toBe(true); + }); + + it('should generate motivational messages for worsened and improved symptom months', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + const month2 = component.months().find((month) => month.monthIndex === 2); + + expect( + month1?.events.some((event) => event.id === 'auto-attention-1') + ).toBe(true); + + expect( + month2?.events.some((event) => event.id === 'auto-improved-2') + ).toBe(true); + }); + + it('should summarize medication adherence for month 1', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + + expect(month1?.medicationTaken).toBe(28); + expect(month1?.medicationExpected).toBe(30); + expect(component.getMedicationAdherenceText(month1!)).toContain('93%'); + }); + + it('should toggle an unlocked month', () => { + const month1Before = component.months().find((month) => month.monthIndex === 1); + + expect(month1Before?.expanded).toBe(false); + + component.toggleMonth(month1Before!); + fixture.detectChanges(); + + const month1After = component.months().find((month) => month.monthIndex === 1); + + expect(month1After?.expanded).toBe(true); + }); + + it('should not toggle a locked month', () => { + const month3Before = component.months().find((month) => month.monthIndex === 3); + + expect(month3Before?.locked).toBe(true); + expect(month3Before?.expanded).toBe(false); + + component.toggleMonth(month3Before!); + fixture.detectChanges(); + + const month3After = component.months().find((month) => month.monthIndex === 3); + + expect(month3After?.expanded).toBe(false); + }); + + it('should render month items in template', () => { + const monthItems = fixture.nativeElement.querySelectorAll( + '[data-testid^="journey-month-"]' + ); + + expect(monthItems.length).toBeGreaterThanOrEqual(6); + }); + + it('should render progress information in template', () => { + const title = fixture.nativeElement.querySelector( + '[data-testid="journey-title"]' + ) as HTMLElement; + + const estimateBadge = fixture.nativeElement.querySelector( + '[data-testid="treatment-estimate-badge"]' + ) as HTMLElement; + + const progressCircle = fixture.nativeElement.querySelector( + '[data-testid="progress-circle"]' + ) as HTMLElement; + + expect(title.textContent).toContain('Sua jornada'); + expect(estimateBadge.textContent).toContain('6 meses'); + expect(progressCircle.textContent).toContain('25%'); + }); + + it('should render month 2 panel expanded by default', () => { + const panel = fixture.nativeElement.querySelector( + '[data-testid="journey-month-panel-2"]' + ) as HTMLElement | null; + + expect(panel).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.ts b/frontend/src/app/features/journey/journey.ts index 2580dba..0cd72fd 100644 --- a/frontend/src/app/features/journey/journey.ts +++ b/frontend/src/app/features/journey/journey.ts @@ -1,10 +1,333 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { JourneyService } from './services/journey-service'; +import { RouterLink } from '@angular/router'; + +export type LeprosyType = 'PB' | 'MB' | ''; +export type JourneyEventType = + | 'treatment-start' + | 'appointment' + | 'medication-summary' + | 'clinical-update' + | 'motivational-message'; + +export type JourneyEventStatus = 'positive' | 'neutral' | 'attention'; +export type SymptomTrend = 'improved' | 'stable' | 'worsened'; + +export interface JourneyEvent { + id: string; + type: JourneyEventType; + title: string; + description: string; + date: string; + monthIndex?: number; + status?: JourneyEventStatus; + metadata?: { + dosesTaken?: number; + dosesExpected?: number; + symptomTrend?: SymptomTrend; + consultationLocation?: string; + }; +} + +export interface JourneyMonth { + monthIndex: number; + label: string; + expanded: boolean; + completed: boolean; + current: boolean; + locked: boolean; + events: JourneyEvent[]; + completedMedicationDays: number; + expectedMedicationDays: number; +} @Component({ selector: 'app-journey', standalone: true, - imports: [], + imports: [CommonModule, DatePipe, RouterLink], templateUrl: './journey.html', styleUrl: './journey.css', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Journey {} +export class Journey implements OnInit { + readonly journeyService = inject(JourneyService); + + readonly patient = this.journeyService.patient; + readonly apiMonths = this.journeyService.months; + readonly summary = this.journeyService.summary; + readonly isLoading = this.journeyService.isLoading; + readonly error = this.journeyService.error; + + readonly expandedMonths = signal>({}); + + ngOnInit(): void { + this.journeyService.loadJourney(); + } + + readonly patientName = computed(() => this.patient().name); + + readonly leprosyType = computed(() => { + return this.summary()?.classification ?? this.patient().leprosyType ?? ''; + }); + + readonly treatmentStartDate = computed( + () => this.summary()?.treatment_start_date ?? this.patient().treatmentStartDate + ); + + readonly events = computed(() => this.journeyService.events()); + + readonly totalMonths = computed(() => { + const apiValue = this.summary()?.treatment_duration_months; + if (apiValue) { + return apiValue; + } + return this.leprosyType() === 'PB' ? 6 : 12; + }); + + readonly totalDays = computed(() => { + return this.summary()?.total_days ?? this.totalMonths() * 30; + }); + + readonly elapsedDays = computed(() => this.summary()?.elapsed_days ?? 0); + + readonly remainingDays = computed(() => { + return this.summary()?.remaining_days ?? Math.max(0, this.totalDays() - this.elapsedDays()); + }); + + readonly progressPercent = computed(() => { + return this.summary()?.progress_percent ?? 0; + }); + + readonly currentMonth = computed(() => { + return this.summary()?.current_month ?? 1; + }); + + readonly estimatedEndDate = computed(() => { + return this.summary()?.estimated_end_date ?? ''; + }); + + readonly months = computed(() => { + const expandedMap = this.expandedMonths(); + + return this.apiMonths().map((month) => ({ + monthIndex: month.month_index, + label: month.label, + expanded: expandedMap[month.month_index] ?? month.status === 'current', + completed: month.status === 'completed', + current: month.status === 'current', + locked: month.status === 'upcoming', + events: month.events + .map((event) => ({ + id: event.id, + type: event.type, + title: event.title, + description: event.description, + date: event.date, + monthIndex: month.month_index, + status: event.status, + metadata: { + dosesTaken: event.metadata?.dosesTaken, + dosesExpected: event.metadata?.dosesExpected, + symptomTrend: event.metadata?.symptomTrend, + consultationLocation: + event.metadata?.consultationLocation ?? event.metadata?.location, + }, + })) + .sort((a, b) => +new Date(b.date) - +new Date(a.date)), + completedMedicationDays: month.medication_summary.doses_taken, + expectedMedicationDays: month.medication_summary.doses_expected, + })); + }); + + readonly hasTreatmentStartDate = computed(() => { + const value = this.treatmentStartDate(); + return !!value?.trim(); + }); + + readonly shouldShowJourneySetupState = computed(() => !this.hasTreatmentStartDate()); + + readonly emptyJourneyTitle = computed(() => + 'Sua jornada de tratamento ainda não começou' + ); + + readonly emptyJourneyMessage = computed( + () => 'Para acompanhar sua evolução, adicione a data de início do tratamento na tela de ' + ); + + readonly emptyJourneyLinkLabel = computed(() => 'Perfil > Meu tratamento'); + + readonly emptyJourneySupportMessage = computed( + () => + 'Depois de informar essa data, a linha do tempo será organizada automaticamente.' + ); + + readonly leprosyTypeLabel = computed(() => + this.leprosyType() === 'PB' + ? 'Hanseníase paucibacilar (PB)' + : 'Hanseníase multibacilar (MB)' + ); + + readonly treatmentEstimateText = computed(() => + this.totalMonths() === 6 + ? 'Estimativa de tratamento: 6 meses' + : 'Estimativa de tratamento: 12 meses' + ); + + readonly progressHeadline = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Adicione a data de início do tratamento'; + } + + if (this.progressPercent() >= 80) { + return 'Você está avançando bem no tratamento'; + } + + if (this.progressPercent() >= 40) { + return 'Seu tratamento segue em andamento'; + } + + return 'Cada etapa cumprida fortalece sua jornada'; + }); + + readonly progressSupportText = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Assim que essa data for informada, mostraremos seu progresso e os marcos da jornada.'; + } + + return `Você já percorreu ${this.elapsedDays()} de ${this.totalDays()} dias previstos do tratamento.`; + }); + + readonly remainingText = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Acesse Perfil > Meu tratamento para informar a data e iniciar sua jornada visual.'; + } + + if (this.remainingDays() <= 0) { + return 'Tratamento previsto concluído.'; + } + + const remainingMonths = Math.ceil(this.remainingDays() / 30); + + return `Faltam aproximadamente ${this.remainingDays()} dias (${remainingMonths} ${ + remainingMonths === 1 ? 'mês' : 'meses' + }) para a estimativa final. Continue com o ótimo trabalho!`; + }); + + readonly displayMonths = computed(() => { + return this.months() + .filter((month) => month.current || month.completed) + .sort((a, b) => b.monthIndex - a.monthIndex); + }); + + toggleMonth(month: JourneyMonth): void { + if (month.locked) { + return; + } + + this.expandedMonths.update((current) => ({ + ...current, + [month.monthIndex]: !month.expanded, + })); + } + + getMonthStatusLabel(month: JourneyMonth): string { + if (month.current) { + return 'Mês atual'; + } + + if (month.completed) { + return 'Etapa concluída'; + } + + return 'Etapa futura'; + } + + getMonthSummary(month: JourneyMonth): string { + if (month.locked) { + return 'Este mês ainda não começou.'; + } + + if (!month.events.length) { + return 'Nenhum registro neste mês até agora.'; + } + + return `${month.events.length} registro(s) e ${month.completedMedicationDays}/${month.expectedMedicationDays} dia(s) completos no ciclo de 30 dias.`; + } + + getMedicationAdherenceText(month: JourneyMonth): string | null { + if (!month.expectedMedicationDays) { + return null; + } + + const percentage = Math.floor( + (month.completedMedicationDays / month.expectedMedicationDays) * 100 + ); + + return `Adesão registrada no mês: ${percentage}% (${month.completedMedicationDays}/${month.expectedMedicationDays} dias completos).`; + } + + getMonthButtonLabel(month: JourneyMonth): string { + if (month.locked) { + return 'Aguardando'; + } + + return month.expanded ? 'Ocultar' : 'Ver detalhes'; + } + + getEventContainerClass(status?: JourneyEventStatus): string { + switch (status) { + case 'positive': + return 'border-emerald-200 bg-emerald-50 text-emerald-900'; + case 'attention': + return 'border-amber-200 bg-amber-50 text-amber-900'; + default: + return 'border-slate-200 bg-slate-50 text-slate-900'; + } + } + + getEventBadgeClass(type: JourneyEventType): string { + switch (type) { + case 'appointment': + return 'bg-sky-100 text-sky-700'; + case 'treatment-start': + return 'bg-violet-100 text-violet-700'; + case 'medication-summary': + return 'bg-emerald-100 text-emerald-700'; + case 'clinical-update': + return 'bg-amber-100 text-amber-700'; + default: + return 'bg-indigo-100 text-indigo-700'; + } + } + + getEventTypeLabel(type: JourneyEventType): string { + switch (type) { + case 'appointment': + return 'Consulta'; + case 'treatment-start': + return 'Tratamento'; + case 'medication-summary': + return 'Medicação'; + case 'clinical-update': + return 'Evolução'; + default: + return 'Mensagem'; + } + } + + trackMonth(_: number, month: JourneyMonth): number { + return month.monthIndex; + } + + trackEvent(_: number, event: JourneyEvent): string { + return event.id; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/journey/services/journey-service.spec.ts b/frontend/src/app/features/journey/services/journey-service.spec.ts new file mode 100644 index 0000000..c272cc6 --- /dev/null +++ b/frontend/src/app/features/journey/services/journey-service.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; + +import { JourneyService, JourneyData } from './journey-service'; + +describe('JourneyService', () => { + let service: JourneyService; + + const mockJourneyData: JourneyData = { + patient: { + id: 'patient-pb-001', + name: 'José da Silva', + leprosyType: 'PB', + treatmentStartDate: '2026-04-05', + }, + events: [ + { + id: 'appointment-m1-001', + type: 'appointment', + title: 'Consulta realizada', + description: 'Consulta na unidade de saúde.', + date: '2026-04-12', + monthIndex: 1, + status: 'neutral', + metadata: { + consultationLocation: 'UBS Centro', + }, + }, + { + id: 'medication-summary-m1-001', + type: 'medication-summary', + title: 'Resumo de medicação', + description: 'Resumo mensal de doses.', + date: '2026-04-30', + monthIndex: 1, + status: 'positive', + metadata: { + dosesTaken: 28, + dosesExpected: 30, + }, + }, + { + id: 'clinical-update-m2-001', + type: 'clinical-update', + title: 'Piora percebida', + description: 'Paciente relatou piora.', + date: '2026-05-10', + monthIndex: 2, + status: 'attention', + metadata: { + symptomTrend: 'worsened', + }, + }, + { + id: 'clinical-update-m2-002', + type: 'clinical-update', + title: 'Melhora percebida', + description: 'Paciente relatou melhora.', + date: '2026-05-18', + monthIndex: 2, + status: 'positive', + metadata: { + symptomTrend: 'improved', + }, + }, + { + id: 'motivational-message-m1-001', + type: 'motivational-message', + title: 'Continue assim', + description: 'Boa adesão ao tratamento.', + date: '2026-04-20', + monthIndex: 1, + status: 'positive', + }, + { + id: 'motivational-message-m2-001', + type: 'motivational-message', + title: 'Atenção aos sintomas', + description: 'Observe sinais e registre mudanças.', + date: '2026-05-12', + monthIndex: 2, + status: 'attention', + }, + { + id: 'appointment-m2-001', + type: 'appointment', + title: 'Retorno mensal', + description: 'Reavaliação do mês.', + date: '2026-05-22', + monthIndex: 2, + status: 'neutral', + metadata: { + consultationLocation: 'UBS Centro', + }, + }, + ], + months: [], + summary: null, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(JourneyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should start with empty state', () => { + expect(service.patient()).toEqual({ + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }); + expect(service.events()).toEqual([]); + expect(service.months()).toEqual([]); + expect(service.summary()).toBeNull(); + expect(service.error()).toBeNull(); + expect(service.isLoading()).toBeFalsy(); + }); + + it('should update journey data', () => { + service.updateJourneyData(mockJourneyData); + + expect(service.patient().id).toBe('patient-pb-001'); + expect(service.patient().name).toBe('José da Silva'); + expect(service.patient().leprosyType).toBe('PB'); + expect(service.patient().treatmentStartDate).toBe('2026-04-05'); + expect(service.events().length).toBe(7); + }); + + it('should expose app and treatment start dates after update', () => { + service.updateJourneyData(mockJourneyData); + + const patient = service.patient(); + + expect(patient.treatmentStartDate).toBe('2026-04-05'); + }); + + it('should expose events for april and may after update', () => { + service.updateJourneyData(mockJourneyData); + + const events = service.events(); + + expect(events.length).toBeGreaterThan(0); + expect(events.some((event) => event.date.startsWith('2026-04'))).toBeTruthy(); + expect(events.some((event) => event.date.startsWith('2026-05'))).toBeTruthy(); + }); + + it('should include appointment events', () => { + service.updateJourneyData(mockJourneyData); + + const appointments = service.events().filter((event) => event.type === 'appointment'); + + expect(appointments.length).toBe(2); + expect(appointments[0].metadata?.consultationLocation).toBeTruthy(); + }); + + it('should include month 1 medication summary', () => { + service.updateJourneyData(mockJourneyData); + + const medicationSummary = service + .events() + .find((event) => event.id === 'medication-summary-m1-001'); + + expect(medicationSummary).toBeTruthy(); + expect(medicationSummary?.type).toBe('medication-summary'); + expect(medicationSummary?.metadata?.dosesTaken).toBe(28); + expect(medicationSummary?.metadata?.dosesExpected).toBe(30); + }); + + it('should include worsening and improvement clinical updates', () => { + service.updateJourneyData(mockJourneyData); + + const worsenedEvent = service + .events() + .find((event) => event.metadata?.symptomTrend === 'worsened'); + + const improvedEvent = service + .events() + .find((event) => event.metadata?.symptomTrend === 'improved'); + + expect(worsenedEvent).toBeTruthy(); + expect(improvedEvent).toBeTruthy(); + }); + + it('should include support and alert messages', () => { + service.updateJourneyData(mockJourneyData); + + const motivationalMessages = service + .events() + .filter((event) => event.type === 'motivational-message'); + + expect(motivationalMessages.length).toBe(2); + expect(motivationalMessages.some((event) => event.status === 'attention')).toBeTruthy(); + expect(motivationalMessages.some((event) => event.status === 'positive')).toBeTruthy(); + }); + + it('should replace existing journey data when updated again', () => { + service.updateJourneyData(mockJourneyData); + + service.updateJourneyData({ + patient: { + id: 'patient-mb-002', + name: 'Maria Oliveira', + leprosyType: 'MB', + treatmentStartDate: '2026-05-02', + }, + events: [], + months: [], + summary: null, + }); + + expect(service.patient().id).toBe('patient-mb-002'); + expect(service.patient().name).toBe('Maria Oliveira'); + expect(service.events()).toEqual([]); + }); + + it('should reset state', () => { + service.updateJourneyData(mockJourneyData); + + service.resetState(); + + expect(service.patient()).toEqual({ + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }); + expect(service.events()).toEqual([]); + expect(service.months()).toEqual([]); + expect(service.summary()).toBeNull(); + expect(service.error()).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/journey/services/journey-service.ts b/frontend/src/app/features/journey/services/journey-service.ts new file mode 100644 index 0000000..02c58a1 --- /dev/null +++ b/frontend/src/app/features/journey/services/journey-service.ts @@ -0,0 +1,184 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { JourneyEvent, JourneyEventStatus, JourneyEventType, LeprosyType } from '../journey'; +import { environment } from '../../../../environments/environment'; + +export interface JourneyPatient { + id: string; + name: string; + leprosyType: LeprosyType; + treatmentStartDate: string; +} + +export interface JourneyData { + patient: JourneyPatient; + events: JourneyEvent[]; + months: JourneyApiMonth[]; + summary: JourneyApiSummary | null; +} + +export interface JourneyApiSummary { + patient_id: string; + user_id: string; + display_name: string | null; + classification: LeprosyType | null; + diagnosis_date: string | null; + treatment_start_date: string | null; + estimated_end_date: string | null; + treatment_status: string | null; + treatment_duration_months: number; + total_days: number; + elapsed_days: number; + remaining_days: number; + progress_percent: number; + current_month: number; +} + +export interface JourneyApiMedicationSummary { + doses_taken: number; + doses_expected: number; + adherence_percent: number; +} + +export interface JourneyApiEvent { + id: string; + type: JourneyEventType; + title: string; + description: string; + date: string; + status: JourneyEventStatus; + metadata?: { + dosesTaken?: number; + dosesExpected?: number; + adherencePercent?: number; + symptomTrend?: 'improved' | 'stable' | 'worsened'; + consultationLocation?: string; + location?: string; + professional?: string | null; + appointment_type?: string; + performed?: boolean; + follow_up?: Record | null; + }; +} + +export interface JourneyApiMonth { + month_index: number; + label: string; + start_date: string; + end_date: string; + status: 'completed' | 'current' | 'upcoming'; + medication_summary: JourneyApiMedicationSummary; + events: JourneyApiEvent[]; +} + +export interface JourneyApiResponse { + summary: JourneyApiSummary; + months: JourneyApiMonth[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class JourneyService { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + private readonly journeyDataState = signal(this.buildEmptyJourneyData()); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + + readonly journeyData = computed(() => this.journeyDataState()); + readonly patient = computed(() => this.journeyDataState().patient); + readonly events = computed(() => this.journeyDataState().events); + readonly months = computed(() => this.journeyDataState().months); + readonly summary = computed(() => this.journeyDataState().summary); + readonly isLoading = computed(() => this.loadingState()); + readonly error = computed(() => this.errorState()); + + loadJourney(): void { + console.log('loadJourney chamado'); + this.loadingState.set(true); + this.errorState.set(null); + + this.http.get(`${this.apiUrl}/v1/patients/me/journey`).subscribe({ + next: (response) => { + console.log('Resposta da API:', response); + this.journeyDataState.set(this.mapApiResponse(response)); + this.loadingState.set(false); + }, + error: (err) => { + console.error('Erro na API:', err); + this.loadingState.set(false); + this.errorState.set('Não foi possível carregar a jornada neste momento.'); + }, + }); + } + + updateJourneyData(data: JourneyData): void { + this.journeyDataState.set(data); + } + + resetState(): void { + this.journeyDataState.set(this.buildEmptyJourneyData()); + this.errorState.set(null); + } + + private mapApiResponse(response: JourneyApiResponse): JourneyData { + console.log('Entrou no mapApiResponse'); + console.log('Response:', response); + const patient: JourneyPatient = { + id: response.summary.patient_id, + name: response.summary.display_name?.trim() || 'Paciente', + leprosyType: response.summary.classification ?? 'PB', + treatmentStartDate: response.summary.treatment_start_date ?? '', + }; + + const events = response.months + .flatMap((month) => + month.events.map((event) => this.mapEvent(event, month.month_index)) + ) + .sort((a, b) => +new Date(b.date) - +new Date(a.date)); + + console.log("PATIENT AND EVENTS: ", patient, events); + + return { + patient, + events, + months: response.months, + summary: response.summary, + }; + } + + private mapEvent(event: JourneyApiEvent, monthIndex: number): JourneyEvent { + return { + id: event.id, + type: event.type, + title: event.title, + description: event.description, + date: event.date, + monthIndex, + status: event.status, + metadata: { + dosesTaken: event.metadata?.dosesTaken, + dosesExpected: event.metadata?.dosesExpected, + symptomTrend: event.metadata?.symptomTrend, + consultationLocation: + event.metadata?.consultationLocation ?? event.metadata?.location, + }, + }; + } + + private buildEmptyJourneyData(): JourneyData { + return { + patient: { + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }, + events: [], + months: [], + summary: null, + }; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/login/login.spec.ts b/frontend/src/app/features/login/login.spec.ts index 7f7f1dd..14017ec 100644 --- a/frontend/src/app/features/login/login.spec.ts +++ b/frontend/src/app/features/login/login.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; import { of, throwError } from 'rxjs'; -import { vi, describe, beforeEach, it, expect } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Login } from './login'; import { AuthService } from '../auth/services/auth-service'; @@ -25,6 +25,12 @@ describe('Login', () => { navigateByUrl: vi.fn(), }; + const toastServiceMock = { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }; + const activatedRouteMock = { snapshot: { queryParamMap: convertToParamMap({}), @@ -33,8 +39,14 @@ describe('Login', () => { beforeEach(async () => { authServiceMock.login.mockReset(); + routerMock.navigateByUrl.mockReset(); routerMock.navigateByUrl.mockResolvedValue(true); + + toastServiceMock.success.mockReset(); + toastServiceMock.warning.mockReset(); + toastServiceMock.error.mockReset(); + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({}); await TestBed.configureTestingModule({ @@ -56,6 +68,22 @@ describe('Login', () => { expect(component).toBeTruthy(); }); + it('should show success toast when registered=true', () => { + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({ + registered: 'true', + }); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + + fixture.detectChanges(); + + expect(toastServiceMock.success).toHaveBeenCalledWith( + 'Cadastro realizado com sucesso.', + 'Agora faça login para continuar.' + ); + }); + it('should not submit when form is invalid', () => { component.form.setValue({ identifier: '', @@ -65,7 +93,12 @@ describe('Login', () => { component.submit(); expect(authServiceMock.login).not.toHaveBeenCalled(); - expect(component.form.touched).toBe(true); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'Preencha e-mail e senha corretamente.' + ); + expect(component.isSubmitting).toBe(false); }); @@ -133,6 +166,10 @@ describe('Login', () => { component.submit(); + expect(toastServiceMock.success).toHaveBeenCalledWith( + 'Login realizado com sucesso.' + ); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/checkin'); expect(component.isSubmitting).toBe(false); }); @@ -215,12 +252,15 @@ describe('Login', () => { expect(toastServiceMock.error).toHaveBeenCalledWith('Falha no login', 'Credenciais inválidas.'); expect(component.isSubmitting).toBe(false); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); }); it('should show default error message when API does not return message', () => { authServiceMock.login.mockReturnValue( - throwError(() => ({ error: {} })) + throwError(() => ({ + error: {}, + })) ); component.form.setValue({ diff --git a/frontend/src/app/features/medication/medication.html b/frontend/src/app/features/medication/medication.html index ca29974..878e810 100644 --- a/frontend/src/app/features/medication/medication.html +++ b/frontend/src/app/features/medication/medication.html @@ -218,4 +218,4 @@