Skip to content

feat: capture the secondary activity and the territorial location - #617

Open
mrivas00 wants to merge 22 commits into
docs/mati/rd-branch-freezefrom
feat/mati/rd-organization-form
Open

feat: capture the secondary activity and the territorial location#617
mrivas00 wants to merge 22 commits into
docs/mati/rd-branch-freezefrom
feat/mati/rd-organization-form

Conversation

@mrivas00

@mrivas00 mrivas00 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Why

The Dominican registry asks an organization for two things the platform had no
field for: a secondary economic activity and an administrative location.
PR 3 added the columns; this fills them in and makes the delete-warning dialog
account for the new reference.

Seventh of the RD stack, based on docs/mati/rd-branch-freeze.

What changes

Secondary economic activity. One flat search over the whole catalog,
labelled Sector — Actividad, not a second sector-plus-activity pair — a hotel
with an agricultural operation has to be able to declare a secondary activity in
another sector.

It does depend on the primary, but only by exclusion: the selector waits until
the primary is chosen — declaring a secondary activity and no primary one is
incoherent, and the API persists secondarySubsectorId without a cross-check —
and the primary is filtered out of its options. Setting the primary to whatever
the secondary already holds clears the secondary, which is otherwise a way to
submit the same activity twice while the field renders empty.

Territorial location. GET /api/territories returns the children of a node,
so each dependent selector loads only the siblings under the node the previous
one produced. The whole tree is never served: the official hierarchy reaches the
sector level, which is roughly thirteen thousand rows. Only the innermost answer
the registrant reaches is stored; the ancestors are derived by walking four
nested parents, which is the whole chain at five levels deep.

A second endpoint, GET /api/territories/levels, says which levels the catalog
actually holds rows for. The form renders one selector per level returned, so the
levels still waiting on a source are absent rather than dead, and appear on their
own the day they load — no hardcoded list to keep in sync. Every rendered
selector stays on screen while it is locked, labelled with the answer that
unlocks it, the way the economic activity behaves while its sector is
unanswered.

Dirección / Región becomes Dirección física, independent of the hierarchy,
and joins the selectors in a location section of its own, laid out as the last
cell of the same two-column grid so an odd number of levels leaves no hole.

The representative's identity document. ID representante becomes
Documento de identidad del representante, with the note saying which one
applies: cédula for Dominican nationals, passport for foreign nationals without
one. The kind of document is not stored — the number identifies it, a cédula
carries the country's fixed format and a passport does not, and nothing reports
on the distinction. Observation 3 asks to "considerar un selector"; a selector
whose value is discarded is worse than none, so this ships the half the
observation states outright and leaves the column for the day something reads it.

Delete warnings count secondary references. The count the dialog shows was
understating the impact: an organization whose only link to a row is its
secondary activity was invisible, and deleting a sector cascades to its
activities all the same. _count cannot express the union of two columns, so the
count moves to a groupBy over both — which is also what makes an organization
that declares one activity as primary and secondary count once instead of
twice.

The activity-unit list follows the primary activity. useOrganizationData builds the intensity-denominator filter from sector and subsector, and the endpoint reads both, but the dialog never passed the subsector — so half the filter was always undefined while the form went on clearing the field every time the primary activity changed, as though it depended on it. The filter is additive (the generic denominators are always included), so passing it can only widen the list. Today it changes nothing: all 19 seeded rows are either generic or per-sector, none per-subsector. It starts mattering the day MMARN adds a denominator for one activity — a data change that would otherwise have failed silently.

Tests

GET /api/territories/levels shipped without an integration test; it has one now. The cases that matter are that the two levels waiting on the IDE-RD layers stay out of the response, and that the order is the hierarchy's rather than the alphabet's — sorted alphabetically the municipality would come first and the selectors would invert.

Two web test files cover the rest: that only the innermost territorial level answered is sent (the ancestors are implied by the hierarchy, and sending them too would let the two disagree), and that a selector whose branch ends stays locked instead of offering an empty list. The Distrito Nacional exercises the last one — it is itself the municipal level, so it has no municipios.

Judgement calls

The territorial catalog stops where the sources do. Article 7 of the Ley
Orgánica de Regiones Únicas de Planificación (núm. 345-22) enumerates the ten
planning regions, the thirty-two provinces and the 157 municipios in one place,
and the ONE's División Territorial 2021 reports the same counts. All three levels
ship from it. The municipal district and the sector/paraje do not: the IDE-RD
layers that carry them are identified but not obtained, and a partial list would
let a registrant find their province and not their municipality, which is worse
than offering neither.

territory_level keeps all five values even so. The observation asks for five
levels, so the model represents five; only the data is partial, and the form asks
the catalog which levels exist rather than assuming. docs/development/rd-territories-sources.md
records the source, what corroborates it, and what is still unverified — the law
is a snapshot of August 2022, and municipios are created and promoted by later
laws.

The catalog also corrected five rows against the law. The first draft followed
Decreto 710-04, which Ley 345-22 replaced. Azua sat under Valdesia rather than El
Valle — which takes Valdesia from four provinces to three and El Valle from two
to three — and four rows carried wording the ministry does not use: Baoruco,
Monte Cristi, Higuamo, Ozama o Metropolitana. The migration in PR 3 carries
the same correction.

seedTerritories is the second path, not the first. PR 3's migration loads
the catalog, because seed.ts skips entirely once a country exists and the RD
environment is already populated. This seeder runs on a database created without
migrations and returns early when the table already holds rows — before reading
the file, which is why the testing fixture is gone and the API tests assert
against the real Dominican hierarchy instead. Its assertions are structural
wherever they can be, so a province MMARN renames does not break the suite.

The new mutation fields are required-nullable, like every other field on
OrganizationMutationDataSchema, which is .strict(). That is why 19 existing
test payloads gain two keys: a client has to be explicit about them.

Also corrects three labels the terminology pass missed — Actividad principal
and Dirección in the profile view, and the whole label set of the
/forms/organizations endpoint, which mirrors the form.

The submission review dialog shows the new fields too. The API already
returned them — getOrgSummaryDetails includes the secondary activity and the
territory with its ancestors — but the dialog never rendered either, so an
administrator approving an inscription could not see the location the registrant
declared. It also still carried Actividad principal and Dirección, two labels
the terminology pass renamed everywhere else. Only the levels the organization
answered are rendered, and the chain-building the profile view did inline becomes
getTerritoryChain, now that two screens present the same location.

And two layout rows the reordering broke. Pairing a field carrying flex-1
with one that does not leaves no free space to distribute, so the flex-1 field
collapses: Cantidad de trabajadores was squeezing the activity unit to a
truncated sliver, and Correo, having lost its class and its spacer, spanned the
whole dialog. The activity unit now sits beside the secondary activity, where the
related questions belong.

Verification

pnpm format, pnpm lint, pnpm type-check and pnpm test:web (723 tests) run
green.

The API tests in this PR are unrun — no Postgres or Docker in the authoring
environment. That covers the territories-endpoint tests — including the three
cases the municipal level introduces: a province returning its municipios, a
municipio as the innermost leaf, and the Distrito Nacional, which is itself the
municipal level and so returns none — plus the two profile-field round trips, the
four reference-count tests, and every existing organization payload the schema
change touches. The seed is also unrun: seedTerritories writes depth by depth,
now three rounds rather than two, and nothing has exercised it against a
database.

No migration. No check runs on this PR: ci.yml triggers on
pull_request: branches: [main].

@mrivas00 mrivas00 added priority: high High priority type: feature new user-facing capability or enhancement area: api apps/api (Fastify backend) area: web apps/web (React frontend) area: packages shared packages/* labels Aug 21, 2026
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from 0629f4f to 24da206 Compare August 24, 2026 12:25
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch 2 times, most recently from 0be1de0 to ecd8724 Compare August 24, 2026 15:10
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from 24da206 to 90dedd8 Compare August 24, 2026 15:10
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch from ecd8724 to c0e6b4a Compare August 24, 2026 17:58
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from 90dedd8 to a46f61a Compare August 24, 2026 17:58
@mrivas00 mrivas00 changed the title feat: capture the secondary activity, territorial location and representative document feat: capture the secondary activity and the territorial location Aug 24, 2026
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from a46f61a to 7bfcbc8 Compare August 24, 2026 19:11
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch from c0e6b4a to 6ac727a Compare August 24, 2026 19:11
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from 7bfcbc8 to 3f0cade Compare August 25, 2026 18:50
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch 2 times, most recently from 6647586 to a4f3634 Compare August 25, 2026 18:52
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from 82adb94 to f65c708 Compare August 26, 2026 12:12
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch from a368a3f to ed205e6 Compare August 26, 2026 12:12
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from f65c708 to 719052a Compare August 26, 2026 12:36
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch from ed205e6 to dceb2a7 Compare August 26, 2026 12:36
The Dominican registry locates an organization by administrative
subdivision, so the seed loads the hierarchy the form selects from. Nodes
declare their own level rather than deriving it from the nesting depth, so a
branch that skips a level in the official catalog loads without a code
change.

The base dataset carries the ten planning regions and the 32 provinces,
which are stable and well established. The three levels below them wait on
the ONE catalog: a partial municipality list would let a registrant find
their province and not their municipality, which is worse than offering
neither. The seeder needs no change to load them.

The migration already loads this catalog, because `seed.ts` skips entirely
once a country exists. This path is what runs on a database created without
migrations, so it returns early when the table already holds rows — before
reading the file, which is why the `testing` fixture is gone. There is no
global count assertion either: the table may hold rows this file does not
describe, and every node is already verified as it is written.

Written depth by depth — two queries per level — because a child needs its
parent's generated id and `createMany` returns none. That keeps the cost
linear in the depth rather than in the node count, which matters once the
sector level lands.
`GET /api/territories` returns the children of a node, or the roots when
`parentId` is omitted, which is exactly what one dependent selector needs.
The whole tree is never served: the official catalog reaches the sector
level, tens of thousands of rows, and a form only ever shows the siblings
under the node just picked.

An empty list is a 200, not a 404 — a leaf of the hierarchy and a level the
catalog has not filled in yet are both expected.
Two fields the Dominican registry asks for reach the organization profile: a
secondary economic activity drawn from the same catalog as the primary one,
and the innermost territorial node the registrant knows.

The territorial ancestors are never stored — the response walks four nested
parents, which is the whole chain at five levels deep, so the form can
prefill its selectors without a recursive query or a closure table.

`cloneOrganizationData` copies the two new columns too: it is what carries a
profile into a re-submission, and a field it forgets is a field the user
silently loses on review.

The form-fields endpoint mirrors the form, so it gains the two keys, renames
the representative identifier to the identity document, and picks up the
terminology the rest of the deployment already uses.
The delete-warning dialog tells an admin how many organizations end up
pointing at a row they are about to soft-delete. With a second reference
into the activity catalog, the `_count` it was reading was understating that
number: an organization whose only link to the row is its secondary activity
was invisible, and deleting a sector cascades to its activities all the same.

`_count` cannot express the union of two columns, so the count moves to a
`groupBy` over both. Grouping rather than summing two counts is what makes
an organization that declares the same activity as primary and secondary
count once instead of twice — the result set is bounded by the number of
distinct activity pairs, not by the number of organizations.
The secondary economic activity is one flat search over the whole catalog,
labelled `Sector — Actividad`, not a second sector-plus-activity pair: a
hotel with an agricultural operation has to be able to declare a secondary
activity in another sector. The primary activity is filtered out of it.

The territorial selectors are dependent and optional — a registrant answers
as far down as they know, and only the innermost answer is stored. Picking a
different ancestor clears everything below it, since a municipality does not
belong to the newly chosen province. A level with no rows is not rendered at
all: while the catalog below province is pending, an empty selector would be
a dead control rather than a missing answer.

`Dirección / Región` becomes `Dirección física`, independent of the
hierarchy, and moves with the selectors into a location section of its own.

The representative's identifier is renamed to the identity document and
carries the note saying which one applies — cédula for Dominican nationals,
passport for foreign nationals without one. The kind is not stored: the
number identifies it, and nothing reports on the distinction.

Also corrects two labels the terminology pass missed in the profile view:
`Actividad principal` and `Dirección`.
Round trips the two new fields through create and read: a territorial answer
returns its ancestors outermost first, and an undeclared one returns an empty
chain.

The reference-count tests are the ones that would have caught the
understated delete warning: an organization reaching a sector only through
its secondary activity is counted, and one that declares the same activity
as both primary and secondary is counted once.

The territories endpoint is asserted against the hierarchy the migration
loads rather than against rows the test creates, so it also covers the parent
links having been built correctly. Its assertions are structural wherever
they can be, because the catalog is real data pending MMARN validation and a
renamed province must not break the suite.

Existing organization payloads gain the two keys: the mutation schema is
strict and its fields are required-nullable, so a client has to be explicit
about them.
The catalog followed the regionalization of Decreto 710-04, which the
Ley Orgánica de Regiones Únicas de Planificación (núm. 345-22, G. O. 11077
del 2 de agosto de 2022) replaced — the law cites that decree among its
`Visto` and supersedes it.

Azua moves from Valdesia to El Valle, which is the one that mattered:
Valdesia drops from four provinces to three and El Valle rises from two to
three, so an organization in Azua was declaring a region the law does not
assign it.

The other four are wording the ministry reads when it validates the
catalog: Baoruco → Bahoruco, Monte Cristi → Montecristi, Higuamo → Higüamo,
and `Ozama o Metropolitana` → Ozama.
Article 7 of Ley 345-22 lists the whole upper hierarchy in one place: the
ten planning regions, the provinces of each, and the municipios of each
province. The municipal level was deferred on the assumption that it waited
on the ONE catalog; it does not. All 157 municipios come from the law, and
the ONE's División Territorial 2021 reports the same count.

`seedTerritories` needs no change — it takes each node's level from the file
and writes depth by depth, so this is one more round.

The leaf test used the province Santiago, which now has ten children. It
moves to the innermost level, resolved structurally rather than by name, and
gains two cases the third level introduces: a province returning municipios,
and the Distrito Nacional returning none, since it is itself the municipal
level and the form must render that selector empty rather than treat the
province as unanswered.
The activity catalog carries a sources document; the territorial one did
not, and its commit message asserted the regions and provinces were "stable
and well established" without citing anything. That is the kind of claim the
activity catalog obliged itself to back.

States the source (Ley 345-22, article 7), the ONE's corroborating counts,
the five rows the law corrected, and why two of the five levels are
deliberately empty — with the sources identified for both, since the gap is
ours rather than the country's: distritos municipales exist in law and
parajes in the census.

Also records the modelling choices MMARN would otherwise discover on its
own: the Distrito Nacional sits at the province level and carries no
municipios, and the five-level shape comes from the observations rather than
from the ONE, whose own hierarchy has more steps.
The official hierarchy is five levels deep, but only the levels an official
source has been obtained for carry rows — today the law's three. The form
has to know which those are, and asking the catalog beats a list written by
hand: the hardcoded version is wrong from the moment data lands until
someone remembers to edit it.

`GET /api/territories/levels` returns them outermost first, ordered against
the enum's declaration rather than by letting Postgres sort the enum, so the
intent survives a value added in the wrong place.

Public like the catalog it describes, and cached on the client for the
session: territories are seed-managed reference data with no maintainer, so
nothing in the app can change the answer.
A selector used to materialise once the level above it was answered, so the
location section grew a field at a time and the shape of the question
changed while it was being filled in. It now mirrors the sector and
economic-activity pair: the level below stays on screen, disabled, and its
label says which answer unlocks it.

Which levels render is no longer inferred from whether the previous answer
produced options — that conflated "you have not answered yet" with "this
level has no data at all". It comes from the catalog, so the two levels
still waiting on their source are absent rather than dead, and appear on
their own the day they load.

A level whose branch genuinely ends stays locked with no options: the
Distrito Nacional is itself the municipal level, so its municipio selector
is empty by law rather than by omission.

The hook is renamed to `useTerritorySelectors`, since `useTerritoryLevels`
now names the query that answers what the catalog holds.
Reordering the form left two rows pairing a field carrying `flex-1` with one
that does not. A MUI field is `fullWidth`, so its flex basis is the whole
row, while `flex-1` sets a basis of zero — put them side by side and there
is no free space to distribute, so the `flex-1` field collapses to nothing.

`Cantidad de trabajadores` was pushing the activity-unit selector down to a
truncated sliver, and `Correo`, having lost its class and its spacer, was
spanning the full dialog while every comparable field takes half.
Task 1.3 assumed all three levels below the province waited on the ONE. Two
do. The municipal level came from article 7 of Ley 345-22, which the change
had not read, and the ONE's own counts corroborate it — so the task narrows
to the municipal district and the sector/paraje, and now names where those
are: the IDE-RD layers, which exist but are not served anonymously.

Also corrects the alternatives note in `design.md`, which counted three
levels as still outstanding.
They were a wrapping row sized by a minimum width, so three levels packed
into a single line while every other row in this dialog is two fields wide.
They now break into rows of two, and a lone selector keeps half the width
instead of spanning the dialog.

Each also says it is optional once it is actionable: the chain is answered
"según aplique", and nothing below the region is required. A locked selector
keeps showing which answer unlocks it, where the suffix would compete with
the instruction rather than add to it.
The address sat on a row of its own, so an odd number of territorial
selectors left a hole above it and the address below one. It is now the last
cell of the same two-column grid, which puts it beside the innermost
selector whenever there is room — and moves it on its own the day a level
lands, without anyone rearranging the section.

`TerritorySelectors` becomes `LocationFields`, since it now lays out the
whole section rather than the selectors alone.
The secondary activity sat alone with a hole beside it while the activity
unit shared a row with the employee count, which reads as though the unit
belongs to the headcount. Pairing the two activity fields puts the related
questions together and leaves the count on its own.
The API already returned them — `getOrgSummaryDetails` includes the
secondary activity and the territory with its ancestors, and the response
schema carries both — but the review dialog never rendered them, so a
reviewer approving an inscription could not see the location or the
secondary activity the registrant declared.

It also still carried two labels the terminology pass renamed everywhere
else: `Actividad principal`, which names intensity denominators rather than
an economic activity, and `Dirección`, which is now the physical address
distinct from the hierarchy.

Only the levels the organization answered are rendered, so the dialog reads
what was declared rather than the shape of the catalog. The chain-building
the profile view did inline becomes `getTerritoryChain`, since two screens
now present the same location the same way.
The selector was enabled from the start, so an organization could declare a
secondary economic activity and no primary one — incoherent, and nothing
rejected it: the API persists `secondarySubsectorId` without a cross-check.

It now waits for the primary, with the label naming what unlocks it, the way
the primary waits for the sector. The catalog it offers is unchanged: the
secondary still spans every sector, because the whole point is that a hotel
with an agricultural operation declares one outside its own.

It also closes a way to store the same activity twice. The options exclude
whatever the primary holds, but that filter runs after the fact: pick a
secondary, then set the primary to it, and the field renders empty while
still submitting the value. Changing the primary now clears the secondary
when the two would collide, and when the primary is cleared outright.
`useOrganizationData` builds its activity filter from a sector *and* a
subsector, and the endpoint reads both — it widens the list additively, so a
subsector-scoped denominator is offered alongside the sector's and the
generic ones. The dialog never passed the subsector, so that half of the
filter was always `undefined`.

Nothing changes today: every row in the Dominican seed is scoped to a sector
or to nothing. It changes the day MMARN adds a denominator for one activity
— which is a data change, and would otherwise silently fail to appear while
the form kept clearing the field every time the primary activity changed, as
if it depended on it.

`selectedActivityId` and the `selectedActivity` it resolved go: the only
caller never passed the id, so the lookup could not return anything.
Every other feature in apps/api carries an integration test; this endpoint
shipped without one. It decides which territorial selectors the organization
form renders, so the cases that matter are that the two levels waiting on the
IDE-RD layers stay out, and that the order is the hierarchy's rather than the
alphabet's — sorted alphabetically, the municipality would come first and the
selectors would invert.

The third case resolves the expected levels from the database instead of
hardcoding them, so loading a further level fails here rather than silently in
the form.
The location work shipped with no test behind it. Two files, both pure enough
to run without mounting the form:

`mappers` — that only the innermost level answered is sent, since the
ancestors are implied by the hierarchy and sending them too would let the two
disagree; and that a chain shorter than the five selectors pads rather than
shifts.

`useTerritorySelectors` — that a level with no rows is never rendered, that a
level loaded later appears without a code change, and that a selector whose
branch ends stays locked instead of offering an empty list. The Distrito
Nacional is the case that exercises the last one: it is itself the municipal
level, so it has no municipios.
@mrivas00
mrivas00 force-pushed the docs/mati/rd-branch-freeze branch from 719052a to c42391b Compare August 26, 2026 20:21
@mrivas00
mrivas00 force-pushed the feat/mati/rd-organization-form branch from dceb2a7 to 68fd5ef Compare August 26, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: api apps/api (Fastify backend) area: packages shared packages/* area: web apps/web (React frontend) priority: high High priority type: feature new user-facing capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant