Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,111 @@ docker compose logs -f app

The protected `status` command produces one `api` event and one `db` event with `client` set to `cli` and the same `request_id`. Credentials and tokens are never included in these logs.

## Package registration and editing (issue #2)

After signing in, the home page shows a form to register and edit packages. Every field is required.

| Field | Form label | Rule |
|---|---|---|
| `trackingCode` | Código de rastreio | 8 to 20 letters or digits; must be unique |
| `customer` | Cliente | 2 to 100 characters |
| `destination` | Destino | 2 to 100 characters |
| `carrier` | Transportadora | One of `CORREIOS`, `JADLOG`, `LOGGI`, `OTHER` |
| `status` | Status | One of `POSTED`, `IN_TRANSIT`, `OUT_FOR_DELIVERY`, `DELIVERED` |
| `postedAt` | Data de postagem | ISO date (`yyyy-MM-dd`); cannot be in the future |

The form shows the Portuguese labels (for example "Em trânsito"), but sends the codes above to the API. Text fields are trimmed before validation. The same rules are enforced in the form, in the API (Bean Validation), and by constraints in the database.

**How to use it:**

1. Sign in and fill in the form.
2. Click **Cadastrar pacote**. On success the page shows "Pacote cadastrado." and the form switches to editing the saved record ("Editar pacote #N").
3. Change any field and click **Salvar alterações** to update it ("Pacote atualizado.").
4. Click **Novo cadastro** to clear the form and register another package.

Validation errors appear under each field. A tracking code that is already registered is rejected with a message on that field.

### Data model

The migration `backend/src/main/resources/db/migration/V2__create_packages.sql` creates the `packages` table:

| Column | Type | Notes |
|---|---|---|
| `id` | `BIGSERIAL` | Primary key |
| `tracking_code` | `VARCHAR(20)` | Not null, unique |
| `customer` | `VARCHAR(100)` | Not null |
| `destination` | `VARCHAR(100)` | Not null |
| `carrier` | `VARCHAR(20)` | Not null; check constraint on the allowed values |
| `status` | `VARCHAR(20)` | Not null; check constraint on the allowed values |
| `posted_at` | `DATE` | Not null |

To inspect the stored data:

```sh
docker compose exec db psql -U findmypacks -d findmypacks -c "SELECT * FROM packages;"
```

### Endpoints

Both endpoints require an authenticated session and a CSRF token (`GET /api/web/csrf`, sent in the `X-XSRF-TOKEN` header). Requests without a valid session receive HTTP 401. The JSON body uses the camelCase field names from the table above.

| Operation | Method and path | Success |
|---|---|---|
| Register a package | `POST /api/web/records` | `201` with the saved record |
| Edit a package | `PUT /api/web/records/{id}` | `200` with the updated record |

Error responses use the body `{"message": "...", "fieldErrors": {"field": "message"}}`:

| Status | When |
|---|---|
| `400` | A field is invalid (`fieldErrors` lists each one) or the body is malformed |
| `401` | No valid session |
| `404` | `PUT` with an `id` that does not exist |
| `409` | The tracking code is already registered by another package |

Example with PowerShell, reusing the `$session` from the login example above:

```powershell
$csrf = Invoke-RestMethod -Uri http://localhost:8080/api/web/csrf -WebSession $session
$body = @{
trackingCode = "BR12345678"; customer = "Maria Silva"; destination = "Fortaleza"
carrier = "CORREIOS"; status = "POSTED"; postedAt = "2026-11-10"
} | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8080/api/web/records -Method Post -WebSession $session `
-Headers @{ "X-XSRF-TOKEN" = $csrf.token; "X-Request-Id" = "manual-test-1" } `
-ContentType "application/json" -Body $body
```

A successful response looks like this:

```json
{
"id": 1,
"trackingCode": "BR12345678",
"customer": "Maria Silva",
"destination": "Fortaleza",
"carrier": "CORREIOS",
"status": "POSTED",
"postedAt": "2026-11-10"
}
```

To edit, send the same body with `-Method Put` to `http://localhost:8080/api/web/records/1`.

### Logs for these operations

Registering a package logs an `api` event with the operation `create`, and editing logs `update`. The same request also produces `db` events with the same `request_id`: `check_tracking_code`, `find_package_by_id` (edit only), and `insert` or `update`. To follow one request, reuse the `X-Request-Id` header and filter the logs:

```sh
docker compose logs app | grep manual-test-1
```

On PowerShell, use `docker compose logs app | Select-String manual-test-1`. Passwords, tokens, and request payloads are not logged.

### Tests

The backend tests for this feature are `PackageDataAccessTest` (data access and `db` logs), `PackageApiIntegrationTest` (endpoints, validation, authentication, and correlated logs), and `AdminUserSeederTest` (keeps the 100% coverage independent of the database state). The form is covered by `PackageForm.test.tsx`. Run them with the command in the next section.

## Run tests

The test service runs frontend and backend tests in containers and uses the Compose PostgreSQL service:
Expand Down Expand Up @@ -123,4 +228,4 @@ Use corresponding distinct directory and branch names for the other contributors

## Deployment

No deployment workflow is configured yet because a deployment target has not been selected. The pull-request CI runs through Docker Compose and verifies both tests and the complete application image.
No deployment workflow is configured yet because a deployment target has not been selected. The pull-request CI runs through Docker Compose and verifies both tests and the complete application image.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.findmypacks.records;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/web/records")
public class PackageController {
private final PackageDataAccess packages;

public PackageController(PackageDataAccess packages) {
this.packages = packages;
}

@PostMapping
public ResponseEntity<PackageResponse> create(@Valid @RequestBody PackageRequest request) {
if (packages.existsByTrackingCode(request.trackingCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tracking code already registered");
}
PackageRecord saved = packages.insert(new PackageRecord(
request.trackingCode(), request.customer(), request.destination(),
request.carrier(), request.status(), request.postedAt()));
return ResponseEntity.status(HttpStatus.CREATED).body(PackageResponse.from(saved));
}

@PutMapping("/{id}")
public PackageResponse update(@PathVariable Long id, @Valid @RequestBody PackageRequest request) {
PackageRecord record = packages.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Package not found"));
if (packages.existsByTrackingCodeAndIdNot(request.trackingCode(), id)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tracking code already registered");
}
record.update(request.trackingCode(), request.customer(), request.destination(),
request.carrier(), request.status(), request.postedAt());
return PackageResponse.from(packages.update(record));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.findmypacks.records;

import com.findmypacks.logging.StructuredOperationLogger;
import com.findmypacks.logging.StructuredOperationLogger.Level;
import java.util.Optional;
import java.util.function.Supplier;
import org.springframework.stereotype.Service;

@Service
public class PackageDataAccess {
private final PackageRecordRepository packages;
private final StructuredOperationLogger operationLogger;

public PackageDataAccess(PackageRecordRepository packages, StructuredOperationLogger operationLogger) {
this.packages = packages;
this.operationLogger = operationLogger;
}

public PackageRecord insert(PackageRecord record) {
return run("insert", "Package record persisted", "Package record insert failed",
() -> packages.save(record));
}

public PackageRecord update(PackageRecord record) {
return run("update", "Package record updated", "Package record update failed",
() -> packages.save(record));
}

public Optional<PackageRecord> findById(Long id) {
return run("find_package_by_id", "Package record read", "Package record read failed",
() -> packages.findById(id));
}

public boolean existsByTrackingCode(String trackingCode) {
return run("check_tracking_code", "Tracking code checked", "Tracking code check failed",
() -> packages.existsByTrackingCode(trackingCode));
}

public boolean existsByTrackingCodeAndIdNot(String trackingCode, Long id) {
return run("check_tracking_code", "Tracking code checked", "Tracking code check failed",
() -> packages.existsByTrackingCodeAndIdNot(trackingCode, id));
}

private <T> T run(String operation, String successMessage, String errorMessage, Supplier<T> action) {
try {
T result = action.get();
operationLogger.log(Level.INFO, "db", operation, "success", successMessage);
return result;
} catch (RuntimeException exception) {
operationLogger.log(Level.ERROR, "db", operation, "error", errorMessage);
throw exception;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.findmypacks.records;

import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;

@RestControllerAdvice(assignableTypes = PackageController.class)
public class PackageExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> invalidFields(MethodArgumentNotValidException exception) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
exception.getBindingResult().getFieldErrors()
.forEach(error -> fieldErrors.putIfAbsent(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", fieldErrors));
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> unreadableBody() {
return ResponseEntity.badRequest().body(new ErrorResponse("Malformed request body", Map.of()));
}

@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> statusError(ResponseStatusException exception) {
return ResponseEntity.status(exception.getStatusCode())
.body(new ErrorResponse(exception.getReason(), Map.of()));
}

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> integrity() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("Tracking code already registered", Map.of()));
}

public record ErrorResponse(String message, Map<String, String> fieldErrors) {
}
}
81 changes: 81 additions & 0 deletions backend/src/main/java/com/findmypacks/records/PackageRecord.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.findmypacks.records;

import jakarta.persistence.*;

import java.time.LocalDate;

@Entity
@Table(name = "packages")
public class PackageRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "tracking_code", nullable = false, unique = true, length = 20)
private String trackingCode;

@Column(nullable = false, length = 100)
private String customer;

@Column(nullable = false, length = 100)
private String destination;

@Column(nullable = false, length = 20)
private String carrier;

@Column(nullable = false, length = 20)
private String status;

@Column(name = "posted_at", nullable = false)
private LocalDate postedAt;

protected PackageRecord() {
}

public PackageRecord(String trackingCode, String customer, String destination, String carrier, String status, LocalDate postedAt) {
this.trackingCode = trackingCode;
this.customer = customer;
this.destination = destination;
this.carrier = carrier;
this.status = status;
this.postedAt = postedAt;
}

public Long getId() {
return id;
}

public String getTrackingCode() {
return trackingCode;
}

public String getCustomer() {
return customer;
}

public String getDestination() {
return destination;
}

public String getCarrier() {
return carrier;
}

public String getStatus() {
return status;
}

public LocalDate getPostedAt() {
return postedAt;
}

public void update(String trackingCode, String customer, String destination,
String carrier, String status, LocalDate postedAt) {
this.trackingCode = trackingCode;
this.customer = customer;
this.destination = destination;
this.carrier = carrier;
this.status = status;
this.postedAt = postedAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.findmypacks.records;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface PackageRecordRepository extends JpaRepository<PackageRecord, Long> {

@Query("select (count(p) > 0) from PackageRecord p where p.trackingCode = ?1")
boolean existsByTrackingCode(String trackingCode);

@Query("select (count(p) > 0) from PackageRecord p where p.trackingCode = ?1 and p.id <> ?2")
boolean existsByTrackingCodeAndIdNot(String trackingCode, Long id);
}
Loading
Loading