Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
083fcd7
Add client list/search API and persistence
evertonschuster Oct 12, 2026
ac2ee1d
feat(services): generalizar a paginação com PageRequest e PagedResult…
evertonschuster Oct 12, 2026
d3aabf3
refactor(services): tipar o filtro de situação das pessoas com o enum…
evertonschuster Oct 13, 2026
b7e4e2d
refactor(services): ler o termo de busca de pessoas como o value obje…
evertonschuster Oct 13, 2026
0f61beb
refactor(services): aceitar texto vazio no SearchText, a busca vazia …
evertonschuster Oct 13, 2026
20e7179
refactor(services): renomear o parâmetro de busca de pessoas de q par…
evertonschuster Oct 13, 2026
c53576e
refactor(services): reunir página e filtros da lista de pessoas em Cl…
evertonschuster Oct 13, 2026
3c1360e
refactor(services): tipar o status do item da lista de pessoas com o …
evertonschuster Oct 13, 2026
e1fa5ce
refactor(services): estender PagedQuery na ListClientsQuery para reap…
evertonschuster Oct 13, 2026
58596fd
refactor(services): converter a lista de serviços ao PageRequest, ao …
evertonschuster Oct 13, 2026
534c46f
refactor(services): a query de lista vira o próprio filtro com ToFilt…
evertonschuster Oct 13, 2026
b5f15ac
refactor(services): tornar os filtros das listas DTOs simples e norma…
evertonschuster Oct 13, 2026
bf384d0
refactor(services): ToFilter() passa a ser método da própria query de…
evertonschuster Oct 13, 2026
ab5187a
refactor(services): nomear os valores do filtro em ListClientsQuery.T…
evertonschuster Oct 13, 2026
b0d6d0c
Update ListClientsQueryHandler.cs
evertonschuster Oct 13, 2026
5946f0a
refactor(services): os dígitos do CPF da busca de pessoas viram o val…
evertonschuster Oct 13, 2026
28cc811
asdasd
evertonschuster Oct 13, 2026
815fc64
test(services): fixar a lista de pessoas com cada cliente inteiro e a…
evertonschuster Oct 13, 2026
ebdf9fd
asd
evertonschuster Oct 13, 2026
002e85c
Simplify client CPF filtering
evertonschuster Oct 13, 2026
ee1ec6e
refactor(services): SearchText volta a recusar texto em branco, como …
evertonschuster Oct 13, 2026
5b06a67
refactor(services): a busca de pessoas tira a máscara do CPF com o Cp…
evertonschuster Oct 13, 2026
ac8ce7d
feat(shared): o kernel lê um enum da query ou da rota por um único no…
evertonschuster Oct 13, 2026
d083170
refactor(shared): renomear AddWireJson() para AddWireFormat() (#155, …
evertonschuster Oct 13, 2026
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
2 changes: 1 addition & 1 deletion .claude/skills/agenza-backend-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Cheap, and it catches most of what matters. Each hit is a question, not a verdic
| `new DomainError(` inside a method | Domain | an unnamed error | Média |
| a `RuleFor` chain with no `.WithErrorCode` | `*Validator.cs` | FluentValidation's internal name leaks to the API | Média |
| a domain type as a member (a shared string value object such as `CpfNumber` is fine, ADR 0055; so is a plain enum, ADR 0058) | commands, inputs, responses | the wire carries strings and primitives | Média |
| a list of enums with no `IsInEnum` rule, a `<Concept>Names` class, a per-enum or per-feature JSON converter | validators, Application, `Program.cs` | an unvalidated value reaches the response; `AddWireJson()` already names every enum (ADR 0058) | Média |
| a list of enums with no `IsInEnum` rule, a `<Concept>Names` class, a per-enum or per-feature JSON converter | validators, Application, `Program.cs` | an unvalidated value reaches the response; `AddWireFormat()` already names every enum (ADR 0058) | Média |
| `//`, `///` | anywhere | "what" comments are not allowed | Baixa |

## 3. Read it, in this order
Expand Down
13 changes: 8 additions & 5 deletions .claude/skills/agenza-backend-slice/references/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ no `Remove` until a delete exists, no `ListAsync` until a list exists.
| `CountBy<Thing>Async(id)` | `int` | an "in use" rule |
| `GetByIdsAsync(ids)` | `IReadOnlyList<T>` | one query for a set — never one per row |
| `ListAsync(filters)` | `IReadOnlyList<T>` | an unpaged list, ordered |
| `ListAsync(page, pageSize, filters)` | `(IReadOnlyList<T> Items, int TotalCount)` | a paged list, ordered |
| `ListAsync(<Entity>ListFilter)` | `PagedResult<T>` | a paged list, ordered: the filter extends `PageRequest` and carries the list's plain filters; the adapter calls `ListPagedAsync(filter, filterAndOrder, ct)` with the root's `Include`s, and `filterAndOrder` returns an `IOrderedQueryable` |
| `Add(entity)`, `UpdateAsync(entity)`, `Remove(entity)` | `void` / `Task` | stage; the handler commits |

Parameters are ids, value objects for value-object columns and plain filter values — never a tenant,
an expression, an `IQueryable` or a specification. Results are the root with its own children — never a
Parameters are ids, value objects for value-object columns and plain filter values — a list with several of them takes
one record of them (`<Entity>ListFilter`) — never a tenant, an expression, an `IQueryable` or a specification. Results are the root with its own children — never a
DTO, a projection or another aggregate.

`GetByIdAsync` returns an aggregate without tracking. In a service with a global no-tracking default,
Expand All @@ -39,8 +39,10 @@ that differs only by tracking (ARCHITECTURE, rules of thumb).
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
`EF.Property<string>(e, "<Property>")`, because a query cannot reach `.Value` through a converter.
- A value-object column is compared as a whole (`c.Cpf == cpf`). A query cannot reach `.Value` through a
converter, and `EF.Property<string>` over such a column throws, so text search or a case-insensitive order on it
needs a stored generated `string` column (`HasComputedColumnSql("lower(\"FullName\")", stored: true)`, read with
`EF.Property<string>(e, "<Column>")`); see `ClientConfiguration` and ADR 0063.
- The `DbContext` already scopes every query to the tenant and hides soft-deleted rows. A repository
never writes a `TenantId` or `DeletedAt` predicate, never calls `IgnoreQueryFilters()`, never calls
`SaveChanges`.
Expand Down Expand Up @@ -85,5 +87,6 @@ The index decides under concurrency. The pre-check exists only to answer with th
- `SqlQuery`, `FromSql`, `ExecuteSql`, `BeginTransaction`, a cache — without evidence in the PR
- an `Id`-only foreign key between two tenant-owned entities
- a list or page without an explicit order
- a list or page whose roots come without their children: `UpdateAsync` on one deletes them
- a literal length in a configuration where a constant exists
- an edited migration that was already committed
20 changes: 13 additions & 7 deletions .claude/skills/agenza-backend-slice/references/use-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ messages", plus the earlier slices for reads, edits and paging. This file is how
- Members are what crosses the wire: primitives, strings, `DateOnly?`, `Guid`, and lists as
`IReadOnlyList<<Thing>Input>?`, with the `<Thing>Input` records in the same file. No domain type —
except a shared string value object such as `CpfNumber?`, which binds from the JSON string and needs
no validator rule (ADR 0055) — no tenant. An enum is allowed: the kernel's `AddWireJson()` already names
every enum in camelCase and refuses integers, in the MVC and the minimal-API JSON options, so there is
nothing to register and no `<Concept>Names` class. A list of enums gets an `IsInEnum` rule per item with
the domain's code (ADR 0058).
no validator rule (ADR 0055) — no tenant. An enum is allowed: the kernel's `AddWireFormat()` already names
every enum in camelCase and refuses integers, in the MVC and the minimal-API JSON options, and reads one from
a query or a route by exactly one name (ADR 0066), so there is nothing to register, no `<Concept>Names` class
and no values chosen to dodge the parser. A list of enums in a body gets an `IsInEnum` rule per item with
the domain's code (ADR 0058); a query enum needs no rule.
- A route id is a member (`<Entity>Id`) that the controller fills with `with { … }`.
- A list query carries its filters as optional members and `Page`/`PageSize` with defaults.
- A list query extends `PagedQuery` (`Page`/`PageSize`, defaults from `PageRequest`) and carries its filters as
optional members, a free-text search as a `SearchText?`; its own `ToFilter()` method builds the
`<Entity>ListFilter` (a `PageRequest` with the list's filters), and the validator writes the three `Paging.*`
rules (ADR 0064).

## 2. The validator — simple and clean

Expand Down Expand Up @@ -108,12 +112,14 @@ No `try/catch`, no base class, no loader or service class between handlers.
- `<Entity>Response`, a `sealed record`, born in the operation folder and moved to the feature root
when a second operation returns it.
- `public static <Entity>Response From<Entity>(<Entity> entity, …)`: a value object becomes its
`.Value` (`?.Value` when optional), an enum its camelCase wire name, each child its own
`.Value` (`?.Value` when optional), an enum stays the enum (the kernel's converter writes its camelCase name; no
`ToString()`), each child its own
`<Child>Response.From<Child>`, another aggregate an `<Other>Summary(Id, Name, …)` built from what the
handler read.
- The DTO decides nothing. A derived value the interface needs is exposed by the domain and copied
here.
- A paged read returns `PagedResult<<Entity>Response>`.
- A paged read returns `PagedResult<<Entity>Response>`: `repository.ListAsync(…)` answers `PagedResult<Entity>` and the
handler `Map`s it.

## 6. Red flags in a use case

Expand Down
16 changes: 10 additions & 6 deletions .claude/skills/agenza-backend-slice/references/value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ Ask in order and stop at the first no:
3. **Does a second service use it?** ARCHITECTURE §1 says anything added to a shared project lands in every
service and "needs a second real caller, not an anticipated one"; ADR 0055 shares a type because its
format is the same everywhere. If only one service uses it today, say so in the PR.
4. **Is it a scalar member of a JSON request body?** That is the only place the kernel binds it. A route or
a query does not bind it (ADR 0055), and a list of them is untested.
4. **Is it a scalar member of a JSON request body, or a text that may travel in a URL?** The kernel binds every
string type from a body. A query or a route binds a type only if it declares
`[TypeConverter(typeof(StringValueObjectTypeConverter<Self>))]` (ADR 0065): a search text does; personal data
(CPF, e-mail, name, phone, notes) never does, because a URL ends up in logs. A list of them is untested.

## 2. Order of work

Expand All @@ -26,11 +28,13 @@ Ask in order and stop at the first no:
- `Create(raw)` returns a `ParseResult<Self>`: trim and normalize, then validate; one pt-BR message per
rule, and a message never carries the value (it reaches the logs and the client).
- `Create(null)`, `""` and whitespace must fail — the converter takes the message of a non-string
token from `Create(null)`.
token from `Create(null)`. A search text too: a blank search is an absent one, and `BlankIsAbsent`
already binds it to `null` before `Create` runs (ADR 0065).
- `Restore(stored)` only rebuilds; no rule runs. Limits are `public const`.
- `BlankIsAbsent` stays `true` (blank binds to `null`, so the member is nullable). Declare `false` only
for a required member that should answer with its own message (`FullName`).
- A date takes `today` as a parameter (`Create(date, today)`); the type never reads the clock.
- Declare the type converter only when rule 4 allows the type in a URL; the contract tests pin which types do.
- Implement `Create` and `Restore` as public static members. An explicit interface implementation
compiles, but the EF converter looks them up by name and fails when the model is built.
2. **Its tests**, in `backend/shared/Admin.SharedKernel.Tests/<Type>Tests.cs`: every rule with its message,
Expand All @@ -41,7 +45,7 @@ Ask in order and stop at the first no:
had, `ToModel` makes no call for it, and the EF configuration keeps only `HasMaxLength` (with the
type's length constant) and `IsRequired`.
4. **The service**, once per service: its Domain references `Admin.SharedKernel.ValueObjects`; the MVC setup
calls `.AddWireJson()`; the OpenAPI setup calls `MapValueObjectsToStrings()`; the `DbContext`
calls `.AddWireFormat()`; the OpenAPI setup calls `MapValueObjectsToStrings()`; the `DbContext`
calls `AddValueObjectConversions()` from `ConfigureConventions`. A service that has the three does
nothing here.
5. **The wire test**: `<Operation>Command<Type>BindingTests` ([tests.md](tests.md) §1).
Expand All @@ -54,8 +58,8 @@ Ask in order and stop at the first no:

- Binding stops at the first invalid value, one field at a time. A missing or `null` member gets the
framework's English "required" message before the type runs.
- A failed `Create` reaches the client as the message of a `JsonException` under the field, with code
`Validation.Failed` (`docs/API.md` §4.3). The type has no `DomainError`, and the validator does not
- A failed `Create` reaches the client as the message of a `JsonException` (a body) or a model error (a query or
a route) under the field, with code `Validation.Failed` (`docs/API.md` §4.3). The type has no `DomainError`, and the validator does not
repeat its rules.
- The EF convention covers only the types of `Admin.SharedKernel.ValueObjects`; JSON and OpenAPI
recognize any type that implements the contract. Keep the type in that project.
Expand Down
122 changes: 119 additions & 3 deletions apps/admin-frontend/src/shared/api/generated/services-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,91 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
get: {
parameters: {
query?: {
Search?: string;
Status?: components['schemas']['ClientStatusFilter'];
Page?: number | string;
PageSize?: number | string;
};
header?: never;
path: {
version: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
'text/plain': components['schemas']['ApiResponseOfPagedResultOfClientListItemResponse'];
'application/json': components['schemas']['ApiResponseOfPagedResultOfClientListItemResponse'];
'text/json': components['schemas']['ApiResponseOfPagedResultOfClientListItemResponse'];
};
};
/** @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: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/problem+json': components['schemas']['ApiProblemDetails'];
};
};
};
};
put?: never;
post: {
parameters: {
Expand Down Expand Up @@ -953,11 +1037,11 @@ export interface paths {
get: {
parameters: {
query?: {
Page?: number | string;
PageSize?: number | string;
Search?: string;
CategoryId?: string;
TagId?: string;
Page?: number | string;
PageSize?: number | string;
};
header?: never;
path: {
Expand Down Expand Up @@ -1813,6 +1897,14 @@ export interface components {
traceId?: null | string;
correlationId?: null | string;
};
ApiResponseOfPagedResultOfClientListItemResponse: {
data: null | components['schemas']['PagedResultOfClientListItemResponse'];
success?: boolean;
/** Format: date-time */
timestamp?: string;
traceId?: null | string;
correlationId?: null | string;
};
ApiResponseOfPagedResultOfServiceResponse: {
data: null | components['schemas']['PagedResultOfServiceResponse'];
success?: boolean;
Expand Down Expand Up @@ -1842,6 +1934,17 @@ export interface components {
id: string;
name: string;
};
ClientListItemResponse: {
/** Format: uuid */
id: string;
fullName: string;
/** Format: date */
birthDate: null | string;
phone: null | string;
email: null | string;
cpf: null | string;
status: components['schemas']['ClientStatus'];
};
ClientResponse: {
/** Format: uuid */
id: string;
Expand All @@ -1857,6 +1960,10 @@ export interface components {
referenceContacts: components['schemas']['ReferenceContactResponse'][];
};
/** @enum {unknown} */
ClientStatus: 'active' | 'inactive';
/** @enum {unknown} */
ClientStatusFilter: 'active' | 'inactive' | 'all' | null;
/** @enum {unknown} */
ContactPurpose: 'emergency' | 'operationalSupport' | 'dailyCommunication';
CreateCategoryCommand: {
name: string;
Expand Down Expand Up @@ -1915,6 +2022,15 @@ export interface components {
phone: null | string;
cpf: null | string;
};
PagedResultOfClientListItemResponse: {
items: components['schemas']['ClientListItemResponse'][];
/** Format: int32 */
totalCount: number | string;
/** Format: int32 */
page: number | string;
/** Format: int32 */
pageSize: number | string;
};
PagedResultOfServiceResponse: {
items: components['schemas']['ServiceResponse'][];
/** Format: int32 */
Expand Down
10 changes: 7 additions & 3 deletions apps/admin-frontend/src/shared/ui/button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/shared/lib/utils';

const buttonVariants = cva(
const buttonStyles = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
Expand Down Expand Up @@ -39,21 +39,25 @@
},
);

function buttonVariants(props?: Parameters<typeof buttonStyles>[0]): string {
return cn(buttonStyles(props));
}

function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
}: ButtonPrimitive.Props & VariantProps<typeof buttonStyles>) {
return (
<ButtonPrimitive
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
className={buttonVariants({ variant, size, className })}
{...props}
/>
);
}

export { Button, buttonVariants };

Check warning on line 63 in apps/admin-frontend/src/shared/ui/button/index.tsx

View workflow job for this annotation

GitHub Actions / frontend-build-and-test

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
Loading
Loading