Repository navigation
fix(admin-features): make admin writes use the field names consumers actually read - #11
Conversation
…Router Absent schemas keep the historical passthrough behaviour so the nine existing features migrate one at a time.
…o correct routes Replace weak toBeDefined() checks with OpenAPI-document introspection so a POST/PATCH schema swap or wrong default fails the test.
Admin writes now use the field names catalog/pricing/availability actually read. Legacy snake_case keys are stripped rather than stored.
price is now required and in major units; the old price_minor key meant every admin-created add-on priced at zero.
discountValue is required and bounded; `active` replaces the inert `is_active`, which promotion-service never read.
PromoCodeUpdate previously dropped both refine rules from PromoCodeCreate, allowing a PATCH to set a >100% discount or an inverted date range. Reapply both rules to the partial schema; percent cap uses the conservative policy - a discountValue >100 is rejected unless discountType === 'FIXED' is explicit in the patch.
…g rules Field names left as-is: neither collection has a backend consumer, so there is no canonical vocabulary to migrate toward.
The closed enum was speculative - sourced from admin-form placeholder text, not a backend contract, since dynamic_pricing_rule has no consumer. It risked rejecting PATCHes of legitimately stored records. COMMON_PRICING_RULE_TYPES is kept as a plain list for the UI's guided picker (Task 12); the API no longer enforces it.
Canonical payloads render correctly; legacy snake_case payloads render as 'Service' with null price, stay visible when deactivated, and price add-ons at zero. The legacy cases define what the migration must repair.
Pure mapping functions plus a dry-run-by-default runner. Never clobbers a document that already carries canonical fields.
Add-ons now migrate the same title/isAvailable/description fields as services (a disabled legacy add-on no longer goes live at a real charge). Drop the base_duration_minutes -> minimumHours mapping since it silently changes pricing floors; flag it for human review instead. Normalize legacy promo codes. Add a read-only --census runner mode, and split the summary into migrated/skipped/conflicting/unrecognised so a genuine legacy-vs-canonical contradiction is never silently dropped. Guard every numeric conversion against NaN.
…flict A document containing both a conflicting field and a cleanly-migratable one is still withheld entirely, but the summary now names the withheld keys per document so an operator knows to re-run after resolving.
…g add-on serviceId migratePromoCode uppercased discount_type verbatim, so the console's old "percentage" placeholder became "PERCENTAGE" which the strict z.enum(['PERCENT','FIXED']) schema rejects -- migrated promos rendered a blank radio and any PATCH carrying the field 422'd. Map known legacy spellings explicitly and flag anything else for human review instead of writing a value the schema will never accept. Also widen AddOnCreate.serviceId to accept null so the admin console can explicitly clear an add-on's service scope on PATCH; omitting the key (the previous approach) left it untouched server-side.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR adds canonical schemas for five admin-feature resources, wires them into CRUD request validation and OpenAPI definitions, and adds pure migration helpers plus a MongoDB census, dry-run, and apply CLI. ChangesAdmin feature contracts and migration
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant AdminFeatureMigrationCLI as migrate-admin-feature-fields
participant MongoDB
participant migrateServiceDefinition
participant migrateAddOn
participant migratePromoCode
AdminFeatureMigrationCLI->>MongoDB: scan configured collections
AdminFeatureMigrationCLI->>migrateServiceDefinition: migrate service definition document
AdminFeatureMigrationCLI->>migrateAddOn: migrate add-on document
AdminFeatureMigrationCLI->>migratePromoCode: migrate promo code document
migrateServiceDefinition-->>AdminFeatureMigrationCLI: migration result
migrateAddOn-->>AdminFeatureMigrationCLI: migration result
migratePromoCode-->>AdminFeatureMigrationCLI: migration result
AdminFeatureMigrationCLI->>MongoDB: apply safe $set patch when --apply is enabled
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (7)
app/server/services/admin-feature-migration.ts (2)
161-187: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider covering the
nameandactivelegacy spellings.
listServicesandlistServiceExtrasinapp/server/services/catalog-service.ts(lines 71-90 and 126-142) already fall back tod.nameandd.active. Documents that carry those keys are therefore not broken for customers, but the migration does not add them toknownKeys. Such documents count asunrecognisedin the runner summary, which makes the census harder to read. Run--censusfirst and confirm whethernameandactiveexist in the real collections.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/server/services/admin-feature-migration.ts` around lines 161 - 187, Check the real collections with --census to confirm whether legacy name and active keys exist, then update migrateServiceDefinition and migrateAddOn to map those spellings to title and isAvailable and include them in knownKeys handling, matching the existing display_name and is_active mappings.
205-222: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
discount_typeis flagged even when the canonical value already exists.If a document has both
discount_type: 'buy_one_get_one'anddiscountType: 'PERCENT', line 208 still pushesdiscount_typeintoflagged. The runner then counts that document under "needs human decision" at line 202. Nothing would be written for that field in either case, because the canonical value is present and always wins. The report asks an operator to act on a document that needs no action.Consider flagging only when
discountTypeis absent.♻️ Proposed change
if ('discount_type' in doc) { const normalized = normalizeLegacyDiscountType(doc.discount_type) - if (normalized === null) { + if (normalized === null) { + if (!('discountType' in doc)) flagForHumanReview(doc, 'discount_type', ctx) + } else if ('discountType' in doc) {Note:
app/tests/admin-feature-migration.test.tslines 146-156 pin the current behavior, so update that test together with this change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/server/services/admin-feature-migration.ts` around lines 205 - 222, Update the discount_type handling around normalizeLegacyDiscountType so an invalid legacy value is flagged only when the canonical discountType field is absent; when discountType exists, preserve conflict detection for differing values and avoid adding the document to human review. Update the corresponding admin-feature-migration test expectations to reflect this behavior.app/tests/admin-feature-migration.test.ts (1)
26-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a conflict assertion to these two cases.
Both cases produce a
conflictinmapField(lines 114-119 ofapp/server/services/admin-feature-migration.ts), not a silent skip. The tests assert only thatpatchstays empty. A regression that drops conflict reporting for service definitions would still pass.💚 Proposed extra assertions
it('maps notes to description only when description is absent', () => { expect(migrateServiceDefinition({ notes: 'n' }).patch.description).toBe('n') - expect(migrateServiceDefinition({ notes: 'n', description: 'd' }).patch.description).toBeUndefined() + const withBoth = migrateServiceDefinition({ notes: 'n', description: 'd' }) + expect(withBoth.patch.description).toBeUndefined() + expect(withBoth.conflicts).toHaveLength(1) }) it('never clobbers an existing canonical title', () => { - const { patch } = migrateServiceDefinition({ display_name: 'Legacy', title: 'Canonical' }) + const { patch, conflicts } = migrateServiceDefinition({ display_name: 'Legacy', title: 'Canonical' }) expect(patch.title).toBeUndefined() + expect(conflicts).toHaveLength(1) })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/tests/admin-feature-migration.test.ts` around lines 26 - 34, Add assertions in both migration tests for the conflict reported by mapField when notes and description, or display_name and title, conflict. Verify the migrateServiceDefinition result preserves the expected conflict entry while retaining the existing patch assertions.app/server/scripts/migrate-admin-feature-fields.ts (2)
92-125: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winConsider isolating per-document write failures.
A single
updateOnerejection propagates out ofrunCollection, out ofmain, and into thecatchat line 230. The process then exits with code 1. The summary for the collections already processed is never printed, and the operator cannot tell how far the run got. The migration is idempotent, so a partial run is recoverable, but only if the operator knows what happened.Catch the error per document, record the
_idin afailedIdslist, and report it in the summary.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/server/scripts/migrate-admin-feature-fields.ts` around lines 92 - 125, Isolate per-document failures in the updateOne call within the runCollection loop so one rejected write does not abort processing or suppress the completed summary. Catch the error for each document, record its _id in a failedIds list, and extend the summary/reporting output to include those failed document IDs while preserving existing migration counters.
183-186: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReport the
_ids forinvaliddocuments.
conflictingdocuments are listed by_idat line 188, so an operator can find and fix them.invaliddocuments get only a count. Line 96 discardsresult.invalid, which holds the offending legacy key names. The operator cannot locate a document with a malformed numeric field, so those documents stay unmigrated on every subsequent run with no way to act on the report.Record the
_idand the invalid field names, then print them like the conflict list.♻️ Proposed reporting change
interface CollectionSummary { ... conflictIds: string[] + /** _id -> legacy keys whose converted value was non-finite. */ + invalidByDocId: Record<string, string[]>- if (result.invalid.length > 0) summary.invalid += 1 + if (result.invalid.length > 0) { + summary.invalid += 1 + summary.invalidByDocId[String(doc._id)] = result.invalid + }if (s.conflictIds.length > 0) { console.log(` conflicting _ids: ${s.conflictIds.join(', ')}`) } + for (const [id, keys] of Object.entries(s.invalidByDocId)) { + console.log(` invalid _id ${id}: non-numeric ${keys.join(', ')}`) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/server/scripts/migrate-admin-feature-fields.ts` around lines 183 - 186, Update the invalid-document handling in the migration flow to retain each invalid document’s _id and offending legacy field names instead of discarding result.invalid. Extend the summary/reporting logic near the existing conflicting-document list to print the invalid entries in the same actionable format, while preserving the current invalid count.app/tests/admin-payload-reaches-consumers.test.ts (1)
67-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGive this block an explicit removal trigger.
These tests assert defects as expected behavior. Line 78 asserts that a service marked
is_active: falseis still shown to customers. The fixtures are hardcoded, so the block stays green after the migration repairs the real data. Nothing signals that the block is obsolete.Two consequences:
- The block never fails, so it cannot tell anyone when the damage is gone.
- If someone later adds a
display_nameoris_activefallback tolistServicesinapp/server/services/catalog-service.ts, line 71 and line 78 fail. A reader without context may revert the fallback instead of deleting the test.Add a comment that names the tracking issue and the condition for deleting the block. State that the fix belongs in the migration, not in a consumer fallback.
Do you want me to open a follow-up issue to remove this block after
--applyruns in production?🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/tests/admin-payload-reaches-consumers.test.ts` around lines 67 - 86, Add a clear comment immediately before the legacy admin payload test block identifying the tracking issue and stating that the block must be removed after the migration’s --apply run completes in production. Clarify that the corrective fix belongs in the migration, not in consumer fallbacks such as listServices, so future failures are interpreted correctly.app/server/schemas/admin-features.ts (1)
103-126: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd a cross-field check tying
priceUnitto the matching price field.
ServiceDefinitionCreateletspriceUnitbe'FLAT'or'HOURLY'whilebasePriceandhourlyRatestay fully independent optional fields. Nothing stops a client from creating aFLATservice with nobasePrice, or anHOURLYservice with nohourlyRate. The docstring says this schema exists specifically because services previously "rendered as 'Service' with no price," so this gap can reproduce the same symptom through the new canonical schema.Confirm with
pricing-service.tswhether it tolerates a missing price for the selected unit, or add a refine to require the matching field.♻️ Proposed refine to require the matching price field
export const ServiceDefinitionCreate = z .object({ title: z.string().min(1), description: z.string().optional(), basePrice: z.number().nonnegative().optional(), hourlyRate: z.number().nonnegative().optional(), minimumHours: z.number().positive().optional(), maximumHours: z.number().positive().optional(), hourIncrement: z.number().positive().optional(), priceUnit: AdminPriceUnit.optional(), currency: z.string().min(1).optional(), isAvailable: z.boolean().optional(), checklist: z.array(z.string()).optional(), service_key: z.string().optional(), }) + .refine((v) => v.priceUnit !== 'FLAT' || v.basePrice != null, { + message: 'basePrice is required when priceUnit is FLAT', + path: ['basePrice'], + }) + .refine((v) => v.priceUnit !== 'HOURLY' || v.hourlyRate != null, { + message: 'hourlyRate is required when priceUnit is HOURLY', + path: ['hourlyRate'], + }) .openapi('ServiceDefinitionCreate')🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/server/schemas/admin-features.ts` around lines 103 - 126, Add a cross-field validation refinement to ServiceDefinitionCreate that requires basePrice when priceUnit is FLAT and hourlyRate when priceUnit is HOURLY, using the pricing-service.ts contract to confirm the expected behavior. Ensure the validation targets the relevant price field and preserves existing validation for all other combinations, including omitted priceUnit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/server/schemas/admin-features.ts`:
- Around line 103-126: Coordinate deployment rather than changing the schema
definitions: update and deploy the admin console to send the current camelCase
field names before releasing ServiceDefinitionCreate, AddOnCreate,
PromoCodeCreate, and PromoCodeUpdate. Verify the console no longer sends legacy
fields such as display_name, base_duration_minutes, is_active, price_minor, or
discount_value, then ship the schema cutover.
In `@app/server/scripts/migrate-admin-feature-fields.ts`:
- Around line 115-119: Update the updateOne filter in the result.changed/apply
branch to require every canonical field in result.patch to remain absent at
write time, preventing stale legacy values from overwriting concurrent admin
changes. Preserve the existing _id filter and do not include code in the
$exists:false guard because it is normalized in place.
- Around line 228-233: Replace the process.exit calls in the main() completion
chain with process.exitCode assignments so pending I/O can finish. Add a
closeClient helper using the MongoClient from app/server/core/mongo.ts, and
invoke it after successful completion and before setting the failure exit code
in the catch handler.
In `@app/server/services/admin-feature-migration.ts`:
- Around line 131-132: Update toNumber and the mapField conversion flow so only
actual numbers and non-empty, numeric strings are converted; preserve valid zero
values. Ensure null, empty or whitespace strings, arrays, booleans, and other
non-numeric inputs remain invalid and are reported instead of being written as
canonical values.
In `@app/tests/admin-feature-migration.test.ts`:
- Around line 146-156: Update the test description and inline comment in the
migratePromoCode test to match the existing assertions: the unrecognised legacy
discount_type should produce no conflict, leave the canonical value unchanged,
and be flagged. Do not change the implementation or assertions.
---
Nitpick comments:
In `@app/server/schemas/admin-features.ts`:
- Around line 103-126: Add a cross-field validation refinement to
ServiceDefinitionCreate that requires basePrice when priceUnit is FLAT and
hourlyRate when priceUnit is HOURLY, using the pricing-service.ts contract to
confirm the expected behavior. Ensure the validation targets the relevant price
field and preserves existing validation for all other combinations, including
omitted priceUnit.
In `@app/server/scripts/migrate-admin-feature-fields.ts`:
- Around line 92-125: Isolate per-document failures in the updateOne call within
the runCollection loop so one rejected write does not abort processing or
suppress the completed summary. Catch the error for each document, record its
_id in a failedIds list, and extend the summary/reporting output to include
those failed document IDs while preserving existing migration counters.
- Around line 183-186: Update the invalid-document handling in the migration
flow to retain each invalid document’s _id and offending legacy field names
instead of discarding result.invalid. Extend the summary/reporting logic near
the existing conflicting-document list to print the invalid entries in the same
actionable format, while preserving the current invalid count.
In `@app/server/services/admin-feature-migration.ts`:
- Around line 161-187: Check the real collections with --census to confirm
whether legacy name and active keys exist, then update migrateServiceDefinition
and migrateAddOn to map those spellings to title and isAvailable and include
them in knownKeys handling, matching the existing display_name and is_active
mappings.
- Around line 205-222: Update the discount_type handling around
normalizeLegacyDiscountType so an invalid legacy value is flagged only when the
canonical discountType field is absent; when discountType exists, preserve
conflict detection for differing values and avoid adding the document to human
review. Update the corresponding admin-feature-migration test expectations to
reflect this behavior.
In `@app/tests/admin-feature-migration.test.ts`:
- Around line 26-34: Add assertions in both migration tests for the conflict
reported by mapField when notes and description, or display_name and title,
conflict. Verify the migrateServiceDefinition result preserves the expected
conflict entry while retaining the existing patch assertions.
In `@app/tests/admin-payload-reaches-consumers.test.ts`:
- Around line 67-86: Add a clear comment immediately before the legacy admin
payload test block identifying the tracking issue and stating that the block
must be removed after the migration’s --apply run completes in production.
Clarify that the corrective fix belongs in the migration, not in consumer
fallbacks such as listServices, so future failures are interpreted correctly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f87bbdd7-4784-41f4-9803-aaeef5f3a500
📒 Files selected for processing (13)
app/server/routes/admin-features/_crud.tsapp/server/routes/admin-features/index.tsapp/server/schemas/admin-features.tsapp/server/scripts/migrate-admin-feature-fields.tsapp/server/services/admin-feature-migration.tsapp/tests/admin-addon-schema.test.tsapp/tests/admin-area-and-rule-schema.test.tsapp/tests/admin-feature-crud-schema.test.tsapp/tests/admin-feature-migration.test.tsapp/tests/admin-payload-reaches-consumers.test.tsapp/tests/admin-promo-schema.test.tsapp/tests/admin-service-definition-schema.test.tsdocs/migration/07-domain-endpoints.md
- toNumber no longer coerces null/''/whitespace/[]/false to 0, so malformed legacy price/discount values are reported as invalid instead of migrating as a canonical zero - migration write now re-checks its precondition via buildUpdateFilter, guarding against a concurrent admin edit during the long scan-and-write pass - migration script closes the Mongo client and uses process.exitCode instead of process.exit - recognise and map legacy name/active spellings, matching catalog-service's existing read-time fallback - fix a test whose name/comment contradicted its own assertions
…own keys Stripping means an older admin-console build gets a 200 while its edit is silently discarded, not that it keeps working. Console deploys first.
Why
The admin console has been writing snake_case field names that no consumer in this repo reads. Traced through the services, not inferred:
price_minornum(d.price) ?? 0—catalog-service.ts:139discount_valuediscountValue ?? value ?? percentage, else0—promotion-service.ts:44is_activeisAvailable ?? active, absent ⇒true—catalog-service.ts:71display_nametitle ?? name, fallback'Service'/'Cleaning'Root cause:
FeatureCreate/FeatureUpdatewerez.object({}).passthrough()with a standingTODO: replace the passthrough shapes. Nine features ran through that factory, so the API accepted arbitrary JSON and nothing ever failed loudly.What changed
crudRouteraccepts optional per-feature schemas. Absent ⇒ today's passthrough, so the four untouched features (cleaner-tags, availability-overrides, payout-adjustments, chat-interventions) behave identically. Five features now validate.Canonical schemas for the three collections that have consumers —
service_definitions,addon_catalog,promo_code— using the exact field names those consumers already read.priceis now required (a priceless add-on was the bug);discountValueis required and bounded.Validation-only schemas for
service_area_boundaryanddynamic_pricing_rule, keeping their existing snake_case names. Deliberate: neither collection has any consumer in this repo, so there is no canonical vocabulary to migrate toward and renaming would be churn.An idempotent migration for documents already stored with legacy names, plus a
--censusmode.docs/migration/07-domain-endpoints.mdrecords this as deliberate change #6, perapp/CLAUDE.md.Things worth a reviewer's attention
z.object— not.passthrough()(which would let the drift back in), not.strict()(which would 422 an old client mid-rolling-deploy). A client sending legacy fields gets a 200 with them dropped.rule_typeis deliberately NOT an enum. It'sz.string().min(1), withCOMMON_PRICING_RULE_TYPESexported purely as a UI suggestion list. An earlier draft constrained it to four values taken from the admin form's placeholder text — with no consumer anchoring them, that would have rejected PATCHes of legitimately-stored records, silently blocking edits.PromoCodeUpdatere-applies its refines. Built from the raw fields object it carried neither, so a PATCH could set a promo to 500% that POST rejects. The bare-discountValuecase takes the conservative branch (reject unless the patch explicitly saysFIXED), documented in a comment and pinned by tests.serviceIdis nullable.delete-ing a key on a PATCH never clears it, so un-scoping an add-on was a silent no-op; the console now sends explicitnull.tests/admin-payload-reaches-consumers.test.tsasserts the CURRENT BROKEN behaviour on purpose. The legacy cases pin that a legacy doc yields title"Service", a null price, an add-on price of 0, and stays visible whenis_active: false. They are the executable definition of what the migration must repair — and the only record of why the alias chains (title ?? name,basePrice ?? price) exist. Please don't delete them as "asserting a bug".The migration — read before running
app/server/scripts/migrate-admin-feature-fields.ts. It has not been run against any database.Intended order is census → dry-run → apply:
Safety properties, each covered by tests:
if (apply). A typo'd flag falls through to read-only.NaN— non-finite conversions are reported, not stored.$setonly, no$unset) so a bad mapping stays auditable. A cleanup pass should be a separate, later script.Two deliberate omissions:
base_duration_minutesis counted and reported, never migrated. Base duration is not a pricing floor; mapping it tominimumHourswould silently change customer quotes on hourly services. Flagged for a human decision.--censusis for. Run it before--apply.Companion frontend PR
The matching console work is in a separate repository:
nathanieluriri/jade-guardianPR #3. That one rebuilds the five Operations Core forms on this vocabulary.Deploy order matters, and I understated this originally. I first wrote that the old console "keeps working because unknown keys are stripped". That is wrong in an important way: stripping means an older console build sending
display_name/is_active/price_minor/discount_valuegets a 200 while its edit is silently discarded. Nothing errors — the admin just watches their change fail to take effect.So: deploy the admin console first, then this. Merging while an old console build is live turns a visible bug into an invisible one. Caught by CodeRabbit; the
07-domain-endpoints.mdentry now says the same thing.Verification
npm test— 463 passing, 46 filesnpm run typecheck— cleannpm run lint— clean (one pre-existing unrelated warning)Summary by CodeRabbit
New Features
Documentation
Tests