BaryoDev / BaryoDev/barakoCMS

Reports built by configuration, and the two things that make it harder than a group by

Open
#349 1 comment 0 reactions 1 assignee Claimed by @arnelirobles View on GitHub
enhancement epic
Dominant language
C#
Stars
6
Forks
7
Avg merge
4h 42m
Merged PRs (30d)
307

Description

"I need an inventory system" is answered by spinning up Docker, designing the objects in barakoBrew (#347) and exposing a portal (#344). Then the first question the client asks is "what is my stock on hand", and there is no answer.

Nothing in the codebase aggregates. `DeliveryQuery` filters, sorts and pages. There is no group by, no sum, no count over a field, anywhere.

## What an inventory system actually asks for

Stock on hand by product. Movements by month. Valuation by category. Items below reorder level. Aging. Every one of those is a grouping plus an aggregate plus a time bucket, over related objects.

## The two things that make this harder than it looks

### It is the most likely place to bypass every access control already built

This is the part worth designing first, because a reporting engine reaches data directly and by construction sidesteps the paths where the rules live.

`ISensitivityService` masks fields per caller on the read paths. A report that groups by a Sensitive field, or sums one, leaks it without ever returning the field: "average salary by department" over three people discloses more than the field would. Tenancy is worse, because a report is exactly the shape of query that forgets a tenant filter and nobody notices until it returns someone else's numbers.

So the rules are not optional extras here:

- A report may not group by, or aggregate over, a field the caller may not read. Refused, not silently dropped, because a report missing a column looks like a report with no matching data.
- Every report query is tenant-scoped through the same session as everything else, never through raw SQL that reconstructs the filter by hand.
- Small-group suppression is worth deciding on rather than discovering. A count of one is a record.

### Content is JSONB, so aggregation has no index to stand on

`mt_doc_contents` indexes `ContentType`, `CreatedAt`, `UpdatedAt`, `Status` and a composite. Those are top-level `Content` properties. Everything a report groups by lives in `data` as JSONB, and there is no index on `data ->> 'anything'`.

Postgres will aggregate JSONB happily and will sequentially scan the table to do it. That is fine for a thousand rows and not fine for a million, and the failure arrives as a slow admin screen rather than an error.

Marten's duplicated fields and computed indexes are the answer, and they mean a report definition has a schema consequence: declaring a report over a field should be able to declare the index for it. That is a design decision, not an optimisation to add later, because retrofitting indexes onto a live table is the expensive version.

## The shape: a query model, not a query language

Same discipline as BaryoDev/barakoCMS#328, and for the same reason. This is edited by whoever configures the system, so an expression field is an injection surface and an unbounded-cost surface in the hands of a non-technical user.

```csharp
public class ReportDefinition
{
public string ContentType { get; set; } = "";
public List Filters { get; set; } = new();
public List GroupBy { get; set; } = new(); // field names, validated against the schema
public List Aggregates { get; set; } = new(); // Sum, Count, Avg, Min, Max over a named field
public TimeBucket? Bucket { get; set; } // day, week, month, quarter, year
public int Limit { get; set; } = 1000;
}
```

Fixed aggregate set, fields validated against the content type, a hard limit with a ceiling. `DeliveryQuery` already validates field names against the schema and binds both name and value as parameters, and this should be built on that rather than beside it.

**An escape hatch, deliberately.** Some reports are genuinely too heavy for this, and pretending otherwise means the answer to a real requirement is "you cannot". A module can register a Marten projection that maintains a purpose-built read model, and a report definition can target it. That keeps configure-not-code for the common case without capping the ceiling, and it is the same core-plus-modules shape as everything else here.

## Output

A table, a chart, and CSV. The chart is a rendering concern over the same result and should not become a second query path. PDF is a bigger question that belongs with document rendering, not here.

## What this needs first

**#141, relations.** "Stock by product category" crosses two objects. Without references, every report is confined to one flat type, which rules out most of the questions above.

## Done when

- A configurer builds "stock on hand by product" and "movements by month" without writing code, and both are correct against a seeded fixture.
- A report grouping by a field the caller may not read is refused, with a test, and a report over readable fields succeeds, so a check that refuses everything cannot pass.
- A report cannot return another tenant's rows, tested at the same join the other tenancy tests use.
- A report over a field with no index either declares one or is refused above a row threshold, rather than quietly scanning.
- A report with no explicit limit does not return an unbounded set.

Part of the 5.0 goal. Related: BaryoDev/barakoCMS#328 (saved queries, the same foundation), BaryoDev/barakoCMS#141, BaryoDev/barakoBrew#4.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.