diff --git a/timeless-api/pom.xml b/timeless-api/pom.xml index b979a85..cba69c8 100644 --- a/timeless-api/pom.xml +++ b/timeless-api/pom.xml @@ -116,6 +116,16 @@ quarkus-junit5 test + + io.quarkus + quarkus-test-security + test + + + io.quarkus + quarkus-test-security-oidc + test + io.rest-assured rest-assured diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java b/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java index 54b6b5e..9112bef 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java +++ b/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java @@ -1,5 +1,6 @@ package dev.matheuscruz.domain; +import dev.matheuscruz.presentation.data.UpdateRecordRequest; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -9,6 +10,7 @@ import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; import java.util.Objects; import java.util.Optional; @@ -27,12 +29,13 @@ public class Record { @Enumerated(EnumType.STRING) private Categories category; private Instant createdAt; + private LocalDate transactionDate; protected Record() { } - private Record(String userId, BigDecimal amount, String description, Transactions transaction, - Categories category) { + private Record(String userId, BigDecimal amount, String description, Transactions transaction, Categories category, + LocalDate transactionDate) { this.userId = userId; this.amount = amount; this.description = description; @@ -40,6 +43,7 @@ private Record(String userId, BigDecimal amount, String description, Transaction this.category = category; this.createdAt = Instant.now(); this.category = Optional.ofNullable(category).orElse(Categories.NONE); + this.transactionDate = Optional.ofNullable(transactionDate).orElse(java.time.LocalDate.now()); } public Long getId() { @@ -70,12 +74,25 @@ public Instant getCreatedAt() { return createdAt; } + public LocalDate getTransactionDate() { + return transactionDate; + } + + public void update(UpdateRecordRequest request) { + this.amount = Objects.requireNonNull(request.amount()); + this.description = Objects.requireNonNull(request.description()); + this.transaction = Objects.requireNonNull(request.transaction()); + this.category = Objects.requireNonNull(request.category()); + this.transactionDate = Objects.requireNonNull(request.transactionDate()); + } + public static class Builder { private String userId; private BigDecimal amount; private String description; private Transactions transaction; private Categories category; + private LocalDate transactionDate; public Builder userId(String userId) { this.userId = userId; @@ -102,18 +119,25 @@ public Builder category(Categories category) { return this; } + public Builder transactionDate(LocalDate transactionDate) { + this.transactionDate = transactionDate; + return this; + } + public Record build() { Objects.requireNonNull(userId, "userId must not be null"); Objects.requireNonNull(amount, "amount must not be null"); Objects.requireNonNull(description, "description must not be null"); Objects.requireNonNull(transaction, "transaction must not be null"); + Objects.requireNonNull(transactionDate, "transactionDate must not be null"); if (transaction == Transactions.IN) { category = Categories.NONE; } category = Optional.ofNullable(category).orElse(Categories.GENERAL); - return new Record(userId, amount, description, transaction, category); + return new Record(userId, amount, description, transaction, category, transactionDate); } + } } diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java index 58bc6ef..ebbf8c8 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java @@ -9,6 +9,7 @@ import dev.matheuscruz.presentation.data.CreateRecordRequest; import dev.matheuscruz.presentation.data.PageRecord; import dev.matheuscruz.presentation.data.RecordItemResponse; +import dev.matheuscruz.presentation.data.UpdateRecordRequest; import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Parameters; @@ -19,6 +20,7 @@ import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.Response; @@ -58,6 +60,27 @@ public Response delete(@PathParam("id") Long id) { return Response.status(Response.Status.NO_CONTENT).build(); } + @PUT + @Path("/{id}") + public Response update(@PathParam("id") Long id, @Valid UpdateRecordRequest req) { + + QuarkusTransaction.requiringNew().run(() -> { + Record record = this.recordRepository.findById(id); + + if (record == null) { + throw new jakarta.ws.rs.NotFoundException(); + } + + if (!record.getUserId().equals(upn)) { + throw new jakarta.ws.rs.ForbiddenException(); + } + + record.update(req); + }); + + return Response.noContent().build(); + } + @POST public Response createRecord(@Valid CreateRecordRequest req) { @@ -85,10 +108,11 @@ public Response getRecords(@RestQuery("page") String p, @RestQuery("limit") Stri // pagination List output = recordRepository.find("userId = :userId", Parameters.with("userId", upn)) .page(Page.of(page, limit)).list().stream().map(record -> { - String format = record.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).toLocalDate() + String createdAt = record.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).toLocalDate() .format(formatter); + String transactionDate = record.getTransactionDate().format(formatter); return new RecordItemResponse(record.getId(), record.getAmount(), record.getDescription(), - record.getTransaction().name(), format, record.getCategory().name()); + record.getTransaction().name(), transactionDate, createdAt, record.getCategory().name()); }).toList(); // calculate total expenses and total in diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java index 7a929f4..4ed03d1 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java @@ -6,7 +6,9 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import java.math.BigDecimal; +import java.time.LocalDate; public record CreateRecordRequest(@PositiveOrZero BigDecimal amount, @NotBlank String description, - @NotNull Transactions transaction, @NotBlank String from, @NotNull Categories category) { + @NotNull Transactions transaction, @NotBlank String from, @NotNull Categories category, + LocalDate transactionDate) { } diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java index 931a453..5bc10a2 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java @@ -2,6 +2,6 @@ import java.math.BigDecimal; -public record RecordItemResponse(Long id, BigDecimal amount, String description, String transaction, String createdAt, - String category) { +public record RecordItemResponse(Long id, BigDecimal amount, String description, String transaction, String date, + String createdAt, String category) { } diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/UpdateRecordRequest.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/UpdateRecordRequest.java new file mode 100644 index 0000000..77a6a21 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/UpdateRecordRequest.java @@ -0,0 +1,10 @@ +package dev.matheuscruz.presentation.data; + +import dev.matheuscruz.domain.Categories; +import dev.matheuscruz.domain.Transactions; +import java.math.BigDecimal; +import java.time.LocalDate; + +public record UpdateRecordRequest(BigDecimal amount, String description, Transactions transaction, Categories category, + LocalDate transactionDate) { +} diff --git a/timeless-api/src/main/resources/application.properties b/timeless-api/src/main/resources/application.properties index f7cc93b..f4d02d6 100644 --- a/timeless-api/src/main/resources/application.properties +++ b/timeless-api/src/main/resources/application.properties @@ -33,4 +33,12 @@ quarkus.quinoa.build-dir=dist/timeless/browser # jwt mp.jwt.verify.publickey=${JWT_PUBLIC_KEY} mp.jwt.verify.issuer=https://timelessapp.platformoon.com/issuer -smallrye.jwt.sign.key=${JWT_PRIVATE_KEY} \ No newline at end of file +smallrye.jwt.sign.key=${JWT_PRIVATE_KEY} +# TEST CONFIGURATION +%test.quarkus.langchain4j.openai.api-key=test-key +%test.quarkus.langchain4j.openai.gpt-4-turbo.api-key=test-key +%test.security.sensible.secret=YS0xNi1ieXRlLXNlY3JldA== +%test.mp.jwt.verify.publickey=test-public-key +%test.smallrye.jwt.sign.key=test-private-key +%test.whatsapp.incoming-message.queue-url=test-queue +%test.whatsapp.recognized-message.queue-url=test-queue diff --git a/timeless-api/src/main/webui/src/app/components/records/records.component.html b/timeless-api/src/main/webui/src/app/components/records/records.component.html index f0b5a77..2f4e1ac 100644 --- a/timeless-api/src/main/webui/src/app/components/records/records.component.html +++ b/timeless-api/src/main/webui/src/app/components/records/records.component.html @@ -1,3 +1,5 @@ + +

Overview

@@ -12,7 +14,9 @@

Overview

-

Saldo

+ +

Saldo

+
@if (eyes()) {

@@ -23,7 +27,9 @@

Overview

}
-

Entradas

+ +

Entradas

+
@if (eyes()) {

{{ totalIn() | currency: "BRL" }} @@ -33,7 +39,9 @@

Overview

}
-

Saídas

+ +

Saídas

+
@if (eyes()) {

{{ totalExpenses() | currency: "BRL" }} @@ -72,14 +80,24 @@

Overview

[severity]="record.transaction == 'IN' ? 'success' : 'danger'" > - {{ record.createdAt }} + {{ record.date }} - +
+ + +
@@ -91,3 +109,87 @@

Overview

[totalRecords]="totalRecords()" [rowsPerPageOptions]="[10, 20, 30]" /> + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/timeless-api/src/main/webui/src/app/components/records/records.component.ts b/timeless-api/src/main/webui/src/app/components/records/records.component.ts index 1b650e0..ba47a11 100644 --- a/timeless-api/src/main/webui/src/app/components/records/records.component.ts +++ b/timeless-api/src/main/webui/src/app/components/records/records.component.ts @@ -1,18 +1,48 @@ import { Component, inject, signal, HostListener } from '@angular/core'; import { TableModule } from 'primeng/table'; import { Tag } from 'primeng/tag'; -import { CurrencyPipe } from '@angular/common'; +import { CurrencyPipe, CommonModule } from '@angular/common'; import { RecordResponseItem, TimelessApiService, + UpdateRecord, } from '../../timeless-api.service'; import { Paginator, PaginatorState } from 'primeng/paginator'; import { Card } from 'primeng/card'; import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { InputText } from 'primeng/inputtext'; +import { InputNumber } from 'primeng/inputnumber'; +import { Select } from 'primeng/select'; +import { DatePicker } from 'primeng/datepicker'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Toast } from 'primeng/toast'; +import { MessageService } from 'primeng/api'; @Component({ selector: 'app-records', - imports: [TableModule, Tag, CurrencyPipe, Paginator, Card, Button], + imports: [ + TableModule, + Tag, + CurrencyPipe, + Paginator, + Card, + Button, + Dialog, + InputText, + InputNumber, + Select, + DatePicker, + ReactiveFormsModule, + CommonModule, + Toast, + ], + providers: [MessageService], templateUrl: './records.component.html', styleUrl: './records.component.scss', }) @@ -28,6 +58,34 @@ export class RecordsComponent { totalExpenses = signal(0); hideTag = signal(false); isMobile = signal(false); + editDialogVisible = signal(false); + selectedRecord = signal(null); + + private fb = inject(FormBuilder); + private messageService = inject(MessageService); + editForm: FormGroup = this.fb.group({ + amount: [0, [Validators.required, Validators.min(0)]], + description: ['', [Validators.required]], + transaction: ['OUT', [Validators.required]], + category: ['GENERAL', [Validators.required]], + date: [new Date(), [Validators.required]], + }); + + categories = [ + { label: 'Custos Fixos', value: 'FIXED_COSTS' }, + { label: 'Lazer', value: 'PLEASURES' }, + { label: 'Conhecimento', value: 'KNOWLEDGE' }, + { label: 'Metas', value: 'GOALS' }, + { label: 'Conforto', value: 'COMFORT' }, + { label: 'Liberdade Financeira', value: 'FINANCIAL_FREEDOM' }, + { label: 'Geral', value: 'GENERAL' }, + { label: 'Nenhum', value: 'NONE' }, + ]; + + transactions = [ + { label: 'Entrada', value: 'IN' }, + { label: 'Saída', value: 'OUT' }, + ]; constructor() { this.checkScreenSize(); @@ -66,6 +124,57 @@ export class RecordsComponent { }); } + showEditDialog(record: RecordResponseItem) { + this.selectedRecord.set(record); + const dateParts = record.date.split('/'); + const dateObj = new Date(+dateParts[2], +dateParts[1] - 1, +dateParts[0]); + + this.editForm.patchValue({ + amount: record.amount, + description: record.description, + transaction: record.transaction, + category: record.category, + date: dateObj, + }); + this.editDialogVisible.set(true); + } + + saveEdit() { + if (this.editForm.valid && this.selectedRecord()) { + const formValue = this.editForm.value; + const formattedDate = formValue.date.toISOString().split('T')[0]; + + const updateRequest: UpdateRecord = { + ...formValue, + transactionDate: formattedDate, + }; + + this.timelessApiService + .updateRecord(this.selectedRecord()!.id, updateRequest) + .subscribe({ + next: () => { + this.editDialogVisible.set(false); + this.populatePaginator(); + this.messageService.add({ + severity: 'success', + summary: 'Sucesso', + detail: 'Registro atualizado com sucesso!', + life: 3000, + }); + }, + error: (error) => { + console.error('Error updating record:', error); + this.messageService.add({ + severity: 'error', + summary: 'Erro', + detail: 'Falha ao atualizar registro.', + life: 3000, + }); + }, + }); + } + } + changeEyes() { this.eyes.update((value) => !value); } @@ -77,8 +186,25 @@ export class RecordsComponent { } deleteRecord(id: number) { - this.timelessApiService.deleteRecord(id).subscribe(() => { - this.populatePaginator(); + this.timelessApiService.deleteRecord(id).subscribe({ + next: () => { + this.populatePaginator(); + this.messageService.add({ + severity: 'success', + summary: 'Sucesso', + detail: 'Registro excluído com sucesso!', + life: 3000, + }); + }, + error: (error) => { + console.error('Error deleting record:', error); + this.messageService.add({ + severity: 'error', + summary: 'Erro', + detail: 'Falha ao excluir registro.', + life: 3000, + }); + }, }); } } diff --git a/timeless-api/src/main/webui/src/app/timeless-api.service.ts b/timeless-api/src/main/webui/src/app/timeless-api.service.ts index 35d4945..752c691 100644 --- a/timeless-api/src/main/webui/src/app/timeless-api.service.ts +++ b/timeless-api/src/main/webui/src/app/timeless-api.service.ts @@ -58,6 +58,10 @@ export class TimelessApiService { return this.httpClient.delete(`/api/records/${id}`); } + updateRecord(id: number, record: UpdateRecord) { + return this.httpClient.put(`/api/records/${id}`, record); + } + logout() { localStorage.removeItem(timelessLocalStorageKey); } @@ -75,10 +79,15 @@ export interface RecordPageResponse { } export interface RecordResponseItem { + id: number; amount: number; description: string; - transaction: string; + transaction: 'IN' | 'OUT'; + date: string; createdAt: string; + category: string; + tag?: string; + icon?: string; } export interface UpdateUser { @@ -88,3 +97,13 @@ export interface UpdateUser { email: string; phoneNumber: string; } + +export interface UpdateRecord { + id: number; + amount: number; + description: string; + transaction: 'IN' | 'OUT'; + date: string; + category: string; + tag?: string; +} diff --git a/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java b/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java new file mode 100644 index 0000000..1719591 --- /dev/null +++ b/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java @@ -0,0 +1,108 @@ +package dev.matheuscruz.presentation; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import dev.matheuscruz.domain.Categories; +import dev.matheuscruz.domain.Record; +import dev.matheuscruz.domain.RecordRepository; +import dev.matheuscruz.domain.Transactions; +import dev.matheuscruz.domain.User; +import dev.matheuscruz.domain.UserRepository; +import dev.matheuscruz.presentation.data.UpdateRecordRequest; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.oidc.Claim; +import io.quarkus.test.security.oidc.OidcSecurity; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class RecordResourceTest { + + @Inject + RecordRepository recordRepository; + + @Inject + UserRepository userRepository; + + @Inject + EntityManager em; + + public static LocalDate fixedDate = LocalDate.of(2026, 1, 1); + + @BeforeEach + @Transactional + void setUp() { + recordRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @TestSecurity(user = "testUser", roles = "user") + @OidcSecurity(claims = { @Claim(key = "upn", value = "testUser") }) + void shouldUpdateRecord() { + // Given + Record record = new Record.Builder().userId("testUser").amount(new BigDecimal("100.00")) + .description("Original Description").transaction(Transactions.OUT).category(Categories.FIXED_COSTS) + .transactionDate(fixedDate).build(); + + saveRecord(record); + + UpdateRecordRequest request = new UpdateRecordRequest(new BigDecimal("150.00"), "Updated Description", + Transactions.IN, Categories.FINANCIAL_FREEDOM, fixedDate); + + // When + given().contentType("application/json").body(request).when().put("/api/records/" + record.getId()).then() + .statusCode(204); + + // Then + em.clear(); + Record updatedRecord = recordRepository.findById(record.getId()); + assert updatedRecord != null; + assert updatedRecord.getAmount().compareTo(new BigDecimal("150.00")) == 0; + assert updatedRecord.getDescription().equals("Updated Description"); + assert updatedRecord.getTransaction() == Transactions.IN; + assert updatedRecord.getCategory() == Categories.FINANCIAL_FREEDOM; + assert updatedRecord.getTransactionDate().equals(fixedDate); + } + + @Test + @TestSecurity(user = "otherUser", roles = "user") + @OidcSecurity(claims = { @Claim(key = "upn", value = "otherUser") }) + void shouldNotUpdateRecordOfAnotherUser() { + // Given + Record record = new Record.Builder().userId("testUser").amount(new BigDecimal("100.00")) + .description("Original Description").transaction(Transactions.OUT).category(Categories.FIXED_COSTS) + .transactionDate(fixedDate).build(); + + saveRecord(record); + + UpdateRecordRequest request = new UpdateRecordRequest(new BigDecimal("150.00"), "Updated Description", + Transactions.IN, Categories.FINANCIAL_FREEDOM, fixedDate); + + // When + given().contentType("application/json").body(request).when().put("/api/records/" + record.getId()).then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "testUser", roles = "user") + @OidcSecurity(claims = { @Claim(key = "upn", value = "testUser") }) + void shouldReturnNotFoundWhenRecordDoesNotExist() { + UpdateRecordRequest request = new UpdateRecordRequest(new BigDecimal("150.00"), "Updated Description", + Transactions.IN, Categories.FINANCIAL_FREEDOM, LocalDate.now()); + + given().contentType("application/json").body(request).when().put("/api/records/999").then().statusCode(404); + } + + @Transactional + void saveRecord(Record record) { + recordRepository.persist(record); + } +}