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
4 changes: 2 additions & 2 deletions .claude/skills/agenza-backend-slice/references/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ that differs only by tracking (ARCHITECTURE, rules of thumb).
- `public class <Entity>Repository : RepositoryBase<<Entity>>, I<Entity>Repository`, constructed from
the service's `DbContext`; registered in Infrastructure's `DependencyInjection.cs`.
- Use the base helpers (`FindAsync`, `ListAsync`, `ListPagedAsync`, `AnyAsync`) and `Set` for the
rest. An update adapter can query only the persisted children it must replace, then explicitly
stage their removal and the new composition. `Include` only the root's own children.
rest. An update adapter can query the persisted children and stage only the difference by id: remove
the ones the aggregate no longer holds, add the ones the database does not have yet. `Include` only the root's own children.
- Order every list and page explicitly, on a key that is unique or ends in one — otherwise rows repeat
or vanish between pages.
- A value-object column is compared as a whole (`c.Cpf == cpf`); text search on it goes through
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/agenza-backend-slice/references/use-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ validator already ran before the handler):
`Find<Thing>ConflictAsync` returning `Error?`; a conflict a form can show is keyed by field, with
`meta`; one per answer.
5. Behaviour, `Add`, or `UpdateAsync`.
6. `SaveChangesAsync`; a failure logs kind and constraint at `Warning` and returns
`<Entity>.SaveFailed`.
6. `SaveChangesAsync`; a failure returns `<Entity>.SaveFailed`. The unit of work has already logged its
kind and constraint at `Warning`, so the handler does not.
7. Return `<Entity>Response.From<Entity>(…)`.

An edit runs `ApplyTo` (or `<entity>.Update(command.To<Entity>Data(), today)`) at step 3, before the pre-checks, because they compare the values it just assigned
Expand Down
200 changes: 200 additions & 0 deletions apps/admin-frontend/src/shared/api/generated/services-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,206 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v{version}/clients/{id}/deactivate': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
version: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiResponseOfClientResponse'];
'application/json': components['schemas']['ApiResponseOfClientResponse'];
'text/json': components['schemas']['ApiResponseOfClientResponse'];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiProblemDetails'];
'application/json': components['schemas']['ApiProblemDetails'];
'text/json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiProblemDetails'];
'application/json': components['schemas']['ApiProblemDetails'];
'text/json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiProblemDetails'];
'application/json': components['schemas']['ApiProblemDetails'];
'text/json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v{version}/clients/{id}/reactivate': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
version: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiResponseOfClientResponse'];
'application/json': components['schemas']['ApiResponseOfClientResponse'];
'text/json': components['schemas']['ApiResponseOfClientResponse'];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiProblemDetails'];
'application/json': components['schemas']['ApiProblemDetails'];
'text/json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiProblemDetails'];
'application/json': components['schemas']['ApiProblemDetails'];
'text/json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiProblemDetails'];
'application/json': components['schemas']['ApiProblemDetails'];
'text/json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v{version}/services': {
parameters: {
query?: never;
Expand Down
1 change: 0 additions & 1 deletion backend/ServiceDefaults/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
Expand Down
7 changes: 4 additions & 3 deletions backend/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,9 @@ meta)`, with `meta` carrying machine context such as the existing record's id an
per answer, in a fixed order. Cheap rejections run before work with side effects (consuming a
sequence, calling out).

**Unit of work.** A failed save is logged at `Warning` with its kind and constraint, and answered as
`Error.Conflict("<Entity>.SaveFailed", "Não foi possível salvar … Tente novamente.")` — no field, no
claim about which rule broke. Application never knows a constraint or index name; the retry goes
**Unit of work.** The unit of work logs a failed save at `Warning` with its kind and constraint, once, for every
handler; the handler only answers `Error.Conflict("<Entity>.SaveFailed", "Não foi possível salvar … Tente
novamente.")` — no field, no claim about which rule broke, no logger. Application never knows a constraint or index name; the retry goes
through the pre-checks and gets the specific answer
([0048](../../docs/adr/0048-database-failures-are-generic-to-the-user.md)).

Expand Down Expand Up @@ -453,6 +453,7 @@ touches that slice, not in bulk.
| Aggregate with children; references to other aggregates | `Client`, `ClientConfiguration` | a navigation to another root filled by an unchecked `SetTags` (`Service.Tags`) | §3, [0049](../../docs/adr/0049-conventions-for-new-backend-slices.md) |
| Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) |
| Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `<Entity>Response.From<Entity>`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) |
| State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) |
| Delete, list, paging | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save; paged query + bounded validator + `PagedResult`) minus the rows above | | §4, §6 |
| Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 |

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
using System.Security.Claims;
using IdentityService.Infrastructure.Identity;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;

namespace IdentityService.Api.Controllers;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
using IdentityService.Application;
using IdentityService.Infrastructure;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Validation.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Scalar.AspNetCore;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Reflection;
using Admin.SharedKernel;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;

namespace IdentityService.Application;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Security.Claims;

namespace IdentityService.Infrastructure.Identity;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

#nullable disable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using Microsoft.AspNetCore.Mvc;
using ServicesService.Application.Clients;
using ServicesService.Application.Clients.CreateClient;
using ServicesService.Application.Clients.DeactivateClient;
using ServicesService.Application.Clients.GetClientById;
using ServicesService.Application.Clients.ReactivateClient;
using ServicesService.Application.Clients.UpdateClient;

namespace ServicesService.Api.Controllers;
Expand Down Expand Up @@ -55,4 +57,26 @@ public async Task<IActionResult> Update(Guid id, UpdateClientCommand command, Ca
var result = await _dispatcher.Send(command with { ClientId = id }, cancellationToken);
return result.ToActionResult(this, client => Ok(client));
}

[HttpPost("{id:guid}/deactivate")]
[ProducesResponseType<ApiResponse<ClientResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken cancellationToken)
{
var result = await _dispatcher.Send(new DeactivateClientCommand(id), cancellationToken);
return result.ToActionResult(this, client => Ok(client));
}

[HttpPost("{id:guid}/reactivate")]
[ProducesResponseType<ApiResponse<ClientResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Reactivate(Guid id, CancellationToken cancellationToken)
{
var result = await _dispatcher.Send(new ReactivateClientCommand(id), cancellationToken);
return result.ToActionResult(this, client => Ok(client));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Admin.Identity.Client;
using Admin.SharedKernel.AspNetCore;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Scalar.AspNetCore;
using System.Text.Json.Nodes;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ServicesService.Application.Abstractions;

public interface IAppointmentRepository
{
Task<bool> HasUpcomingAppointmentsAsync(Guid clientId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
using Admin.SharedKernel;
using Microsoft.Extensions.Logging;
using ServicesService.Application.Abstractions;

namespace ServicesService.Application.Clients.CreateClient;

public sealed class CreateClientCommandHandler(
IClientRepository clientRepository,
IUnitOfWork unitOfWork,
TimeProvider timeProvider,
ILogger<CreateClientCommandHandler> logger) : ICommandHandler<CreateClientCommand, ClientResponse>
TimeProvider timeProvider) : ICommandHandler<CreateClientCommand, ClientResponse>
{
public async Task<Result<ClientResponse>> Handle(CreateClientCommand command, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -40,11 +38,6 @@ public async Task<Result<ClientResponse>> Handle(CreateClientCommand command, Ca
var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken);
if (saveResult.IsFailure)
{
logger.LogWarning(
"Saving a client failed with {Kind} on {ConstraintName}",
saveResult.Error.Kind,
saveResult.Error.ConstraintName);

return Result.Failure<ClientResponse>(Error.Conflict(
"Client.SaveFailed",
"Não foi possível salvar a pessoa. Tente novamente."));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Admin.SharedKernel;

namespace ServicesService.Application.Clients.DeactivateClient;

public sealed record DeactivateClientCommand(Guid ClientId) : ICommand<ClientResponse>;
Loading
Loading