Repository navigation
Conversation
mrivas00
left a comment
There was a problem hiding this comment.
Code review — catalog factor server authority
Reviewed the full diff of this PR: 15 findings, 9 of them inline below. The rest are on lines this PR does not touch (or belong to another PR of the chain), so they are grouped here.
Findings on lines outside this diff
[media] apps/api/src/features/carbonInventories/getEmissionFactors/service.ts:98 — rows are deduplicated by catalog emissionFactor.id alone, but appliedFactorYear / appliedFactorValue are per line, so once a factor is re-dated the lines that applied different vintages collapse into a single row and the new "Año del factor" column under-reports the mismatch. Fix: deduplicate on (emissionFactorId, appliedFactorYear, appliedFactorValue) so each applied vintage gets its own row.
[media] apps/web/src/screens/CarbonInventory/components/EmissionEditor/hooks/useEmissionEditorForm.ts:149 — resetFactorValueFields / resetFactorRelatedFields clear baseFactorId, factorValue, factorRateMeasurementUnitId and factorSource but not the two fields this PR adds (emissionFactorId, appliedFactorYear), so a line left with no factor (unit change with no unique recommendation) keeps triggering the year-mismatch alert. Fix: clear both fields in the resets, or drop the baseFactorId / emissionFactorId duplication on EmissionCaptureFormLine so they cannot diverge.
[baja] apps/web/src/screens/CarbonInventory/components/EmissionEditor/services/emissionFactorService.ts:48 — getAvailableSources has no callers left after the move to ID-keyed selection, and apps/api/src/utils/index.ts:7 still re-exports CUSTOM_FACTOR_SOURCES, which the API no longer imports. Fix: delete both, so the source-as-identity idiom this PR removes is not left lying around.
Belongs to the schema PR of the chain (feat/mati/emission-factor-year-schema), not to this one
[alta] packages/database/src/prisma/migrations/20260901120000_add_emission_factor_year/migration.sql:46 — the preflight hard-codes the four source strings of the seed dataset and raises on anything else, but the maintainer source cell is a free-solo input, so any real database (the on-prem deployment included) aborts the migration and blocks prisma migrate deploy. Fix: default unknown sources to a transversal year (NULL) and report them, keeping the exception for genuine data inconsistencies.
[alta] packages/database/src/prisma/migrations/20260901120000_add_emission_factor_year/migration.sql:142 — conflict detection compares numeric division results via count(DISTINCT base_value), and two mathematically equivalent factors expressed in different units differ in the last digit, aborting the migration over consistent data. Fix: round to a fixed scale (or apply an explicit tolerance) before the DISTINCT.
[media] apps/web/src/screens/Maintainer/hooks/useEmissionFactorColumns.tsx:510 — the year cell reuses EditableNumberCell, which maps any unparseable input to null, and null here means "transversal, applies to every reporting year", so a typo silently redefines the factor's semantics and makes it win the TRANSVERSAL rank for every inventory year. Fix: use a dedicated year cell that distinguishes an intentionally empty value from an invalid one.
d298a0d to
96a9436
Compare
…e-factor selection
Line sync branches on the discriminated factor selection instead of guessing from the source string. For a CATALOG selection the factor is loaded inside the sync transaction — so the row cannot be edited or deleted between validation and the write — and four things are checked: it is ACTIVE, it belongs to this inventory's methodology version and the line's subcategory, its required dimension values match the line, and the requested applied unit shares its magnitude family. A conversion across families is not a conversion, so that is a rejection rather than a best effort. Value, source, year and the conversion into the requested unit are then read and computed from the row, and the line result follows from the converted value. Client-authored value, source and year are gone from the write path: the CATALOG variant is strict, so a request that tries to supply them is refused outright. An updated line's subcategory comes from the stored line rather than the request, so catalog validation is always scoped to the line it belongs to. CUSTOM keeps its own path and persists `emissionFactorId = null` and `appliedFactorYear = null`; DIRECT gets no factor snapshot at all. That is what keeps both out of the year-mismatch warning by construction. Inventory duplication copies `appliedFactorYear` with the other immutable snapshots — never re-resolved, so re-dating the copy changes nothing.
`appliedFactorYear` is a historical snapshot: it records the vintage applied when a line was saved and is never rewritten, not when the catalog is edited and not when the footprint year changes. So whether a line disagrees with the current footprint year is derived, from pure helpers, at render time — a stored flag would go stale on the first re-dating. The factors-used summary gains its own year column. A null applied year is a transversal or custom factor, neither of which has a vintage that can disagree with anything, so both render as an em dash with an explanation rather than being styled as a problem. A real mismatch is marked, and its tooltip says the calculation was not modified. The helpers are shared rather than local to the summary because the capture screen needs exactly the same rule for its subcategory warning.
The Factor selector was keyed on source text, which cannot tell two vintages of one provider apart, and auto-loaded a factor whenever the activity had exactly one source. It is now keyed on the canonical factor ID, and ranks candidates against the footprint year: exact year, then transversal, then nearest earlier year, then nearest later. Earlier beats later because a factor published before the footprint year describes conditions closer to it. A factor is preselected only when the winning rank holds exactly one canonical factor. Two providers publishing for the same year are two defensible scientific choices and picking between them belongs to the organization, so array order, source text and factor ID are never used as tie-breakers. Candidates are deduplicated by canonical ID first, or a factor available in many units would look like a tie with itself. It stays one control labelled `Factor`, with no separate year selector: a dated option reads `DEFRA (2025)` and a transversal one just `IPCC`, since the parentheses are what mark a factor as dated. `Otro` keeps its place in the same list. A saved choice is restored by ID on reload and is never replaced by a newer recommendation. Saving sends only the canonical ID and the desired unit. The subcategory warning recomputes from the current rows on every render, so it follows a reload, a single factor edit and a footprint-year change with no invalidation step — and it blocks nothing.
The write path took a Decimal(28,10) through Number.parseFloat before storing it as the line's applied factor, so a value beyond JavaScript's safe precision was rounded — and that rounded number is what the stored emission is computed from. convertEmissionFactorValueDecimal is now the real conversion; the string variant the read path uses delegates to it and only stringifies the result, so the two can no longer drift and neither turns the value into a double.
…tract Every request in the file still sent factorSource, baseFactorId, appliedFactorValue, appliedFactorRateMeasurementUnitId and manualTotalEmissions, and none sent inputType or factorSelection. The schemas are strict, so all of them were rejected with a 400 — the file documented a contract that no longer exists. Rewritten around the four selection variants, with a header explaining what each one means and why a catalog selection deliberately omits value, source and year. Adds an UNCHANGED example, a catalog-factor rejection, and the create that must fail because UNCHANGED is not part of the create union. carbon-inventories.rest keeps its stale line examples: they describe a PATCH /lines endpoint that no longer exists at all, which is a separate cleanup.
The overflow case asserted float behaviour: 1e300 * 1e300 * 1e300 became Infinity and was rejected. In decimal arithmetic it is simply computed, so the test now pins the exact result instead. Adds the case that motivated the change — a value with more significant digits than a double holds, which parseFloat rounded away on its way into the stored snapshot.
mapFactorSelection tested the source text before baseFactorId, so a catalog factor whose provider is named like the custom-factor label was submitted as a CUSTOM selection with the client's own value — bypassing the catalog identity, the year snapshot and every server-side check.
resetFactorValueFields cleared baseFactorId but not emissionFactorId or appliedFactorYear, so a line left with no factor — a unit change with no unique recommendation — kept reporting a year mismatch against a factor it no longer had.
A retired factor drops out of the methodology payload, so the saved baseFactorId matched no option and the Select rendered blank — the same as a line that never had a factor, with an out-of-range warning in the console. The cell now renders the state explicitly and keeps the value unselectable.
The selector treated a factor's blank dimension as a wildcard while the API requires an exact match on every required position, so a factor left blank there was offered — and could even be recommended — only for the save to fail with a dimension mismatch.
The conversion threw CatalogEmissionFactorNotFoundError with a rate-unit id, so the user was told their emission factor no longer exists — naming the wrong entity, the wrong id, and asking them to reselect a factor that is fine.
The tagged union makes the pair representable, and nothing checked it: a SIMPLIFIED line carrying a DIRECT total persisted the total with no result row — a number in the editor that counts as zero everywhere — while a DIRECT line carrying a catalog selection wrote a factor snapshot no result reads.
The summary read the source from the live catalog row while reading the year from the line snapshot, so a renamed factor produced a provider/vintage pair that never existed — in the table and in the Excel export alike.
Rows were deduplicated by catalog factor id, but the year and the value are snapshotted per line: once a maintainer re-dates a factor, the lines holding the older vintage collapsed into the newer row and their year warning disappeared from the summary.
Moving the persisted conversion to Decimal dropped the finiteness check on the result, and nothing bounded it against the column it is written to. A non-finite result is now rejected for both callers, and a value too large for applied_factor_value fails as a typed data error instead of a numeric overflow mid-transaction.
Each catalog line ran its own required-dimension query and two rate-unit queries, all of them repeats within a subcategory save: forty lines meant around a hundred and sixty sequential round-trips inside the transaction, against Prisma's five-second budget. The shared part is now read once per request and the rate units are cached as they are seen.
The row id joins the factor, its applied year and its applied value; a transversal factor has no year, and an empty segment made the id read as a typo. It says "transversal" instead.
Two lines holding two vintages of one re-dated factor must stay two rows; the existing assertions on the row id move to the vintage-keyed form.
With one affected line and one vintage the alert read "1 de 1 línea … usan factores de 2020": the verb and the factor phrase stayed plural, and the test asserted the ungrammatical form.
The summary table gained an "Año del factor" column and the workbook did not, so the copy people send to a reviewer was the one without the vintage — the place a stale factor matters most.
The required-dimension query filtered on the literal "ACTIVE" while the query right above it already read its status from the enum.
The docblock still said the string variant goes through a double and can round the value, which its own docblock — and the code, since it delegates here — contradict.
The non-null assertion holds only because the validation loop above throws for any update id missing from the stored lines; a reader had to reconstruct that.
Until the server owned the snapshot, the capture screen reopened every line with no factor id and the API wrote emission_factor_id from that same field, so a line edited after a reload kept its provider and lost its identity. Those lines came back with an empty Factor cell, and the next edit either let the recommendation replace a deliberate choice or dropped the factor and the line's emissions with it. Provider, applied value, rate unit and dimension values are matched back to the catalog, and anything ambiguous stays empty rather than guessing a vintage.
96a9436 to
78370d4
Compare
|
Rebased onto the refreshed base of the chain (the schema PR's tip a00092e) and pushed with a lease. The 25 commits replayed without conflicts and one new commit was added, so every SHA quoted in the threads above has moved. Current mapping of the review fixes:
Every review thread is resolved. One condition to carry forward, from the thread on the sync helper: this PR must not be merged into main on its own — the UNCHANGED variant that stops a re-save from rewriting a historical snapshot lands in the next PR of the chain, so 628 merges behind 629, or neither does. |
What & why
feat/mati/emission-factor-year-schema. Plan:openspec/changes/add-emission-factor-year/.Risk / blast radius — read first
POST /api/carbon-inventories/:id/lines/syncreplaces the ambiguous factor fields with a discriminated union —CATALOG { emissionFactorId, appliedRateMeasurementUnitId },CUSTOM { source, value, rateMeasurementUnitId },DIRECT { totalEmissions }. Web is adapted in the same PR because the old and new shapes cannot both type-check; the front and the back must land together.Key changes & decisions
1. Discriminated union instead of inferring the factor kind from the source string
syncCarbonInventoryLines/schemas.ts,types.ts(+3) · 8f0134esourceagainst a constant list.CUSTOMandDIRECTkeep their dedicated validations, and custom lines persistemissionFactorId = null/appliedFactorYear = null.2. Server re-derives value, source and year inside the sync transaction
syncCarbonInventoryLines/helper.ts,service.ts(+6) · cb6beedACTIVEstatus, methodology/subcategory membership, required dimension values and applied-unit family compatibility before anything is written.3. The conversion runs in decimal arithmetic, not floating point
getCarbonInventoryMethodology/helper.ts,syncCarbonInventoryLines/helper.ts· 5598beb, 8e1e760numbersilently rounded factor magnitudes.Decimal(28,10); doing the arithmetic inPrisma.Decimalend-to-end is the only way the persisted value matches the catalog exactly. 8e1e760 pins it with a case a double loses.4. Vintage ranking: exact year → transversal → nearest earlier → nearest later
EmissionEditor/services/emissionFactorService.ts(+9) · 44ed5695. One
Factorcontrol, not a separate vintage selectorEmissionEditorFactorSourceCell.tsx,useEmissionEditorColumns.tsx(+2) · 44ed569DEFRA (2025), transversal ones show only the source; the existingOtrooption and its custom-factor fields stay in the same list.6. Reload restores the snapshot, not the current recommendation
useEmissionCaptureData.ts,emissionCaptureTransformers.ts(+2) · 44ed5697. Mismatch warning is derived, informational, and per subcategory
factorYearMismatch.ts,useEmissionFactorsColumns.tsx(+4) · bb2a6dcTest plan
Automated gates that actually ran on this branch:
pnpm format:check && pnpm lint && pnpm type-check— green.pnpm test:api -- /carbonInventories --coverage=false— 549/549 (includes the newcatalogFactorAuthoritysuite).pnpm test:api -- /emissionFactors --coverage=false— 60/60.pnpm test:web— 870/870. Remaining api domains run in the CI matrix.Manual, by risk — not run in this session:
value→ rejected, and the stored applied value still equals the catalog row.appliedFactorYearcopied verbatim.Chain — merge in order:
UNCHANGEDvariant + batched catalog lookupsOut of scope
UNCHANGEDselection variant and the batched catalog lookups →feat/mati/emission-factor-unchanged-selection.