Repository navigation
feat: shared product configurator for galleries and CRM (#1924) - #2222
Conversation
|
Thanks a lot for picking this up, and for the care that went into it! The revision and retry handling, the PNG re-encode path, the event scoping on the admin routes and the backup coverage are really solid, and the docs page made the PR easy to follow. I ran the branch locally end to end (catalogue → assign to a gallery → client submits → lock → client sees it read-only) to get a feel for the flow. Your draft also helped us think through the open questions in #1924, and in doing so we realised we need something broader than the issue described. So before you spend more time polishing this shape, here's where we'd like to take it. Very happy to discuss! Where we'd like to take #1924What we need is a generic product configurator, not one tailored to albums. Products with several attributes and optional add-ons should be modelled freely by the admin. To illustrate, one example among many: a large-format print in 60×40 / 90×60 / 120×80, with an optional wood frame (oak / walnut / black) and an optional passe-partout. A photobook with size, cover, paper and page count would be another. Neither fits exactly one model and one colour per product, and the design shouldn't assume any particular product. So:
We understand keeping it separate from quotes was deliberate, because historical quote contents must not change. Snapshot-on-accept covers that: the accepted quote keeps a frozen copy, the same way your choice history already does. Choosing in the gallery after the photo selection can stay as an additional entry point that uses the same picker and the same snapshot. Things to fix regardless of directionBlockers
Concerns
UI/UX guide (
Migrations
PR body
Happy to talk through the direction on #1924 before you put more time into this shape. |
|
Thanks for the detailed review. I agree with the broader direction and am revising this draft accordingly. CRM integration is in scope now, alongside the standalone gallery entry point. Proposed shared contract:
I will first land the four blockers and regression coverage (including actual core migrations on PostgreSQL), then the shared catalogue/picker and CRM path in reviewable commits on this draft. The album layout/proofing/export work remains separate from this product foundation. I will also address the native UI, notification, permission and migration concerns you listed. Does this match the intended ownership and snapshot boundaries? I am proceeding with those boundaries while keeping the PR draft so adjustments remain reviewable. |
14d09f3 to
389447c
Compare
|
The four blockers are fixed in 389447c, rebased onto fb6702f (including the inquiry change):
Also covered: description-only changes do not block locking; submitted choices cannot silently return to draft; boolean no-op writes do not create fake revisions. Validation for this commit: The shared generic catalogue/CRM design is proposed in the comment above. I have kept the wider implementation out of this blocker commit so this milestone can be reviewed independently; the PR remains draft. Updated screenshot attachments and PR-body corrections follow. |
|
Thanks for the quick turnaround! I checked out
I also ran One remaining gap of the same kind as blocker 4:
On the shared designYes, this matches the ownership and snapshot boundaries we had in mind: one library, the CRM item owning prices, quotes copying the definition when authored, server-side recompute against the confirmed total, and a frozen spec on acceptance that contracts and invoices carry.
Thanks again for taking this on! |
|
Thanks, Luca — I agree with the adjustments and have brought the CRM work into this PR. The shared design document now records the agreement. The updates cover the archived-gallery deletion blocker, the shared option/image library, frozen CRM configurations, gallery billing, quote pre-fill and locked choices, retained document images, and native VAT, decimal-input and money handling. Gallery prices default to hidden, and attribute dependencies remain limited to one prerequisite per attribute. One deliberate billing safeguard: automatic append is limited to exactly one compatible unissued, unarmed project invoice. Ambiguous, armed or incompatible invoices leave the item open for photographer review; issued invoices are never changed. Unlock removes an unbilled item, while billed choices require the native cancellation/Storno path. A revision check also prevents issuing a stale PDF if a product is appended during rendering. Validation passed: 194 SQLite tests across 15 related suites, 19 CRM and 13 gallery tests on PostgreSQL 15 using real core migrations, and 16 frontend tests, plus focused lint and the production frontend build. The PR description includes updated synthetic UI screenshots and the existing unrelated TypeScript limitations. The PR remains draft pending upstream review and workflow approval. All of this is included in #2222. |
the-luap
left a comment
There was a problem hiding this comment.
This is a review of the CRM part (307f23d71, c935eb252) and the migrations, on head c935eb252. Luca's earlier rounds covered the gallery-only shape, and I did not re-review that. I approved the fork workflow runs; most checks are green, and two were still running when I posted this.
🔴 Blockers
1. Archiving a catalogue item breaks every draft quote that contains it, with albumProducts off too
backend/src/services/albumProducts/crm.js:131-141:
if (!line.catalogue_preset_id) return finish(out);
...
const preset = await db('quote_line_item_presets')
.where({ id: line.catalogue_preset_id })
.first();
if (!preset || !isTruthyFlag(preset.is_active))
throw new AppError('The catalogue item is unavailable', 400);
if (!preset.product_id) return finish(out);- In quote mode,
LineItemsTablenow stampscataloguePresetIdon every line inserted from the catalogue, package sub-items included (cataloguePresetId: mode === 'quote' ? preset.id : undefined). So this hits ordinary catalogue items, not only configurable products. deleteLineItemPresetis a soft delete (is_active = false,quoteService.js:2744).- So once a studio archives a catalogue item, a draft quote that contains such a line can no longer be saved: it returns 400, "The catalogue item is unavailable". Reissue, template publish and quote-from-template go through the same
prepareLineand fail the same way. - This is a regression for every CRM user, whether or not the configurator is on.
Fix: return finish(out) for a preset without product_id before the active check. Only reject an inactive preset when a configurable line is being newly added.
2. Migrations 316–318 collide with open PRs #2232 and #2233
#2232 ships 316_add_agb_cms_page.js, and #2233 ships 317_photo_sales.js / 318_photo_sales_email_template.js. Whichever lands second has to renumber. This is not something you did wrong, only a merge-order coordination point. Please rebase and renumber to the next free numbers right before this leaves draft, and update every path that cites them.
🟡 Concerns
- Route inventory misses six routes.
adminAlbumProducts.js:114-199registersGET /,GET /billing,POST /billing,POST /offers,GET /offers/:id/historyandPUT /offers/:idon theeventssub-router (events.get/post/put). The gate inusageCoverageInventory.test.js:20only matchesrouter.<verb>(, so CI cannot see that they are absent fromdocs/usage-coverage.v6.json. Please add them underadminAlbumProducts.js. Ideally, also widen the regex, or register them asrouter.<verb>('/events/:eventId/…'). - With CRM billing off, unlock skips the owner check. At
adminAlbumProducts.js:213-216,if (!on) return next();drops bothrequireEventOwnerandbills.manage. Butbilling.unlock(billing.js:97-136) runs no matter what the flag says, andauditedDeletes the openproduct_billable_items. Items created while billing was on therefore survive the flag being turned off, and a gallery-assigned admin withproducts.configurecan then delete them. Fix: always require the owner for lock and unlock, or have unlock refuse while open items exist and billing is off. - The public quote never sends
configurationLocked.publicDocumentViews.js:147-149projectsconfigurationDefinitionandconfigurationSnapshotonly.QuoteResponsePage.tsxchecks!li.configurationLocked, so a locked line renders as an editable picker, and the client only learns otherwise from the server's 409CONFIGURATION_LOCKED. AddconfigurationLocked: isTruthyFlag(li.configuration_locked)there, and check the portal projection for the same gap. - Re-accepting an unchanged configurable quote counts as a change. In
quoteService.js:1612, the!configurable &&guard skips the "same choice" early return for any quote with a configurable line. Every identical re-accept inside the response window then goes through the full path again, and so does every double-click. Inferred from that path: another business mail, another-accepted-NPDF and an emptyselection_changesentry. Compare the configuration snapshot as well, and returnnullwhen nothing changed. - Gallery clients receive internal CRM ids.
service.js:137-149shape()includescustomerId(billing_customer_id) andcataloguePresetId, andclientOffersspreads...rowinto the client response. Strip them on the client path. - Invoice totals are summed by hand in
append(billing.js:279-293). It adds the line intonetand recomputes VAT from it. As far as I read it, that bypasses the shared total helper, thecrm_invoice_round_totalsetting and percentage discount lines on the target invoice. That is inferred from reading, not reproduced. Reload the lines and run the same total function that the invoice editor uses. - Package parent totals may go stale when a configured sub-item's price changes on accept.
writeSnapshots(crm.js:255-286) rewrites only the configured lines. Inferred, not reproduced: please check what the quote PDF and contract render for a package with a configured sub-item. - Product-linked catalogue items become uneditable once the flag is off or the product or one of its options is deactivated.
presetColumns(crm.js:51-73) re-runscatalogueProducton every PUT that carriesproduct_id, and the UI always sends it, which gives 403/400. The same 403 hits adding such an item to a quote, or publishing a template that has one (crm.js:37-38). Only validate whenproduct_idactually changes. - Unchecked stamp writes. At
billing.js:268-274and408-414, the billable item is stamped with.whereNull('invoice_id'), but the affected row count is never checked. If it is 0, an invoice line is created without a link back. Throw a 409 on 0. configurationsentries are not validated. Atcrm.js:170,c.positionis read on raw body elements, so anullentry gives a 500. Addbody('configurations.*').isObject().- Migration 318 has no idempotency guards.
createTable,alterTableandCREATE FUNCTION/CREATE TRIGGERall run withouthasTable/hasColumnand withoutIF NOT EXISTS/OR REPLACE. On PostgreSQL the runner wraps each migration in a transaction (run-migrations.js:36), but on SQLite it does not. So if the backfill loop (the contract retention pass) fails half-way, the next boot fails ontable already existsand stays down. Please guard it the way 316/317 are guarded. - Template prices are frozen at publish time (
quoteTemplateService.js:422-428). Quotes created from a template later carry the CRM base and surcharge prices from when the template was published. If that is intended, please say so indocs/album-products.md.
🟢 Nits
billing.js:291passesupdated_at: new Date()to knex. The repo convention is.toISOString()(see CLAUDE.md, Jest + SQLite).- Migration 316 seeds
albums.edit, and 317 renames it toproducts.configure. Neither migration has been released, so 316 could seed the final names directly, and the rename step in 317 could go. - The public and portal
product-imagesroutes are not behindalbumProducts. That is reasonable for frozen documents, but worth a sentence in the docs. - An admin
changeAddOnson an accepted quote can't book a configured add-on that has no snapshot ("Choose X"), and the accepted quote can't be edited to fix that.
✅ Verified
Directly inspected:
- Migrations 316/317/318 and the 109 edit: read in full. 316/317 are guarded, 318 is not (concern 11). The PG trigger and the SQLite
RAISE(ABORT)trigger both protect referenced images. The backfill only retains snapshots from accepted or converted quotes. server.js: one mount,/api/admin/album-products, behindadminAuthplusrequireFeatureFlag('albumProducts')at the router level.- Fork CI: the PR touches no
.github/, package or Docker files, and I approved its workflow runs. - The blocker, concerns 1–5, the bypass in concern 2 and the
unlockdelete path: each re-read at the cited line onc935eb252.
Checked by a second reviewer pass and spot-read by me, but not traced end to end:
- Price recompute: the unit price comes from the stored definition plus definition surcharges. Option ids are validated against the frozen product, and the client cannot set quantity, discount or unit price.
expectedTotalMinoris compared as an exact integer. The response window,assertQuoteNotConvertedand the conditional status update are intact. - Freeze on accept: it happens in the same transaction as acceptance, and the invoice PUT refuses line or VAT changes on frozen lines.
- Lock path: the catalogue and offer rows and the candidate invoice are taken
FOR UPDATE, withunique(offer_id, offer_revision)and theproduct_items_revisioncheck insendInvoiceOnce. - Image upload: PNG magic bytes,
sharpmetadata and a re-encode, with server-generatedalbum-products/<uuid>.pngkeys. - Tenancy: billable items are scoped to their creator, and portal and public routes stay behind
ownedDocument/hasValidGrant.
Not inspected: customer erasure with open product items, the frontend components outside the quote pages, and the test suites themselves (194 + 32 + 16 trusted at PR-body level).
Verdict
Close blocker 1, and concerns 1–5 and 11, before this leaves draft. Renumber the migrations against whatever has landed by then. The remaining concerns need a fix or a clear answer.
c935eb2 to
e2e5dce
Compare
|
Updated this draft in 1811a27, ec623f5 and e2e5dce, rebased onto main The review follow-up covers:
Two decisions are now explicit in the existing workflow docs and covered by tests:
Final focused validation: 182 SQLite tests / 14 suites, 54 PostgreSQL tests (39 CRM + 13 gallery + 2 migration), and 31 frontend tests / 6 files passed. Production build and lint pass with existing warnings. Chromium checks passed at 1440px/390px; new screenshots are in the PR body. The body also records the broader suite runs and the seven existing TypeScript diagnostics addressed by #2246. Checks for the new head are running; schema drift has passed. Keeping this draft for maintainer review. |
the-luap
left a comment
There was a problem hiding this comment.
I re-verified 1811a27b, ec623f5c and e2e5dce6 on head e2e5dce6. 13 of the 14 points are closed at file:line, and the remaining one is a narrower form of blocker 1. Thanks also for widening the inventory scanner instead of only listing the routes.
Blockers
| Status | Evidence on e2e5dce6 |
|
|---|---|---|
| 1. Archived catalogue item breaks drafts | crm.js:146-150 now returns finish(out) for a preset without product_id before the active check. Lines that already carry a definition return early at :117-135, and reissue/duplicate stop at :114. The gap left is below. |
|
| 2. Migration numbers | ✅ | 320–322. No 31[678]_product reference is left, and the tests require the new paths. |
Remaining gap in 1: an existing plain line is upgraded on its next save
crm.js:117-153: if configuration_source_line_id resolves to a source line without a definition, the code falls through to the preset lookup, exactly as for a brand-new line. Scenario:
- A draft has an ordinary "Album" line from the catalogue.
- The studio then links that catalogue item to a product. That is allowed, since C8 now permits editing.
- On the next draft save, the existing line silently becomes configurable, and its unit price is reset to
baseUnitPriceMinor, overwriting any price the photographer typed. - If the item is archived by then, the save returns 400 again. If
albumProductsis off,catalogueProductreturns 403.
Quote-from-template takes the same path. As far as I read it, toServiceLine passes the preset id without a definition, so the line picks up the current catalogue price, against the frozen-template-price decision you documented. I inferred this from the code and did not trace it end to end.
Fix: when a source line resolves without a definition, return finish(out) without consulting the preset. Only a line that is newly added from the catalogue (no source line) should be upgraded. A regression test would be: save a draft → link the preset to a product → save again → the line stays plain at its old price.
Concerns — closed
| Status | Evidence | |
|---|---|---|
| 1. Route inventory | ✅ | usageCoverageInventory.test.js:21-22 collects every const X = (express.)Router( name. Only adminAlbumProducts.js changes (6 → 12), and it matches usage-coverage.v6.json. Small nit: the comment mentions "routers passed into modules", which the regex does not detect. |
| 2. Owner check on lock/unlock | ✅ | adminAlbumProducts.js:215-220: requireEventOwner always runs first, and bills.manage is added only when billing is on. |
3. configurationLocked |
✅ | publicDocumentViews.js:148. The portal uses the same buildQuoteView. |
| 4. Identical re-accept | ✅ | quoteService.js:1631-1638 returns null, and writeAcceptedSelection re-checks under forUpdate (:1450-1455). PDF and mail are gated on selectionChanged. |
| 5. Internal ids to clients | ✅ | service.js:151-156 clientShape covers clientOffers and all non-admin mutate returns. |
| 6. Hand-summed invoice totals | ✅ | billing.js:278-305 uses computeTotals with crm_invoice_round_total. |
| 7. Package parent totals | ✅ | crm.js:274-279 writes line_total_minor for lines without a definition. |
| 8. Product-linked items uneditable | ✅ | crm.js:64-67 skips validation when the link and the surcharges are unchanged. |
| 9. Unchecked stamp writes | ✅ | billing.js:274-275, :428-429: stamped !== 1 returns 409. |
10. configurations.* |
✅ | crm.js:178, plus isObject() on all four routes and on admin /add-ons. |
| 11. Migration guards | ✅ | 322: hasTable/hasColumn throughout. PG uses CREATE OR REPLACE FUNCTION and a pg_trigger existence check, SQLite uses CREATE TRIGGER IF NOT EXISTS, and retention uses onConflict().ignore(). |
| 12. Template price freeze | ✅ | Documented in album-products.md. 320 seeds the final products.* permissions, and the rename is gone. The billing timestamp is an ISO string. |
New in e2e5dce6: add-on configuration on an accepted quote
This looks right. It requires quotes.manage, calls assertQuoteNotConverted, requires status accepted and checks the definition version. The frozen check (quoteService.js:2063-2073) lets through only add-ons that are newly booked or being removed. Every other configured line that differs gets 409 CONFIGURATION_FROZEN. I found no path that silently changes an accepted spec.
🟢 Nits
- Discounts on appended products:
appendnow re-resolves percentage promotion lines throughcomputeTotals, so a quote's percentage promotion also discounts gallery products appended later. That follows from what I asked for, butinvoice/create.jsdoes not re-resolve promotions, so please state it inalbum-products.md. - Screenshots: the amendment screen for accepted-quote add-ons is new UI and has no screenshot yet.
✅ Verified
- Read directly: the full diff of the three fix commits,
crm.js, migration 322, the permission block of 320 and the guards of 321,resolveCustomerSelection/writeAcceptedSelection,adminChangeAddOns,computeTotalsinappend, andclientShape. - Scanner: the old and new inventory regexes were run over the whole route tree.
- CI is green on
e2e5dce6. - Trusted at PR-body level: the 182 SQLite, 54 PostgreSQL and 31 frontend tests.
Verdict
Close the remaining gap in blocker 1 (an existing plain line must never be upgraded) with a test, add the amendment screenshot, and from my side this can leave draft.
|
Fixed the remaining plain-line preservation gap in
Validation under Node 22.23.3: 170 SQLite tests / nine suites, 49 PostgreSQL 15 CRM tests, and backend/touched-test lint pass (one existing unrelated console warning). Both database runs use real core migrations and synthetic fixtures. The accepted-quote add-on amendment screenshots are embedded in the PR description; direct links for convenience: desktop, phone. Those assets are present on The PR description now separates this follow-up's validation from the earlier broader runs. Keeping it draft while the updated head's GitHub checks run. |
|
Re-verified 2e8c650. The remaining part of blocker 1 is closed, and both nits from the last round are done. Blocker 1 — closed
Nits — closed
Still needed
✅ Verified
VerdictOnce it is rebased and CI is green, and you take it out of draft, merge cleared from my side. |
|
Updated in Validation: 170 SQLite tests and 49 PostgreSQL tests passed. Lint has no errors, with one existing unrelated warning. No deployment changes. |
2e8c650 to
8830b38
Compare
|
Rebased onto main Full CI is green, including backend/frontend tests, browser smoke tests, AMD64/ARM64 builds, clean install and upgrade checks. No unresolved review threads remain. The subsequent main commit |
|
Checked the rebased head 8830b38. I trial-merged this PR together with #2249, #2260 and #2262 onto The one overlap is Merge cleared. |
Resolves the GalleryView.tsx overlap with the merged client PIN entry PR: the client-access dialog and the product panel both stay.
the-luap
left a comment
There was a problem hiding this comment.
Merge cleared, see the review thread.

Behaviour and scope
Clients configure shared catalogue products from their gallery or while booking included/optional products on a quote. Quotes keep their offered definitions and prices; acceptance freezes the chosen specification for contracts and invoices. A later gallery choice becomes a new billable product item.
Implements the direction agreed with Luca-Timo in #2222 (comment) and contributes to #1924. Gallery and CRM work remain together in this draft. Album layout/proofing/export, payment collection and supplier ordering remain separate.
Review fixes and decisions
26 October follow-up: existing ordinary quote lines retain their authored price and plain status after their catalogue preset is linked to a product. Published templates freeze the absence of configuration too, including package children; new catalogue lines and explicitly republished templates still use current definitions/prices. Ten new regressions cover these boundaries.
Percentage promotions on unissued invoices are documented as being recalculated when gallery products are appended. The route-scanner comment now describes only locally declared named routers. Accepted-quote amendment screenshots are embedded below: desktop, phone.
Archived ordinary catalogue entries continue to work in existing quotes and previously published templates, including with the configurator disabled. Editing an unchanged product association no longer revalidates a disabled product/option. New associations, option-price changes and new definitions still require an active product and the feature; configurable catalogue items retain fixed pricing.
Gallery ownership now guards lock/unlock independently of the billing flag, protecting retained financial items after billing is disabled. Assigned admins do not receive owner billing controls. Public/portal quotes expose the configuration lock; gallery client reads/writes omit internal customer/catalogue IDs.
Identical acceptances and concurrent identical amendments produce one selection-history entry, PDF and relevant email. Malformed configuration entries return 400 at HTTP and domain boundaries. Conditional invoice-item stamps must affect one row or roll back with 409.
Invoice appends use the shared totals calculation, including percentage discounts, rounding and shipping. Configured package parent totals are persisted consistently with quote PDFs and contract snapshots.
The route inventory includes all six event subrouter routes; its scanner now detects named routers. Backup-path expectations, editable seeded catalogue classification and affected overview test fixtures are updated. Catalogue viewers get a complete read-only view; legacy controls use shared labelled inputs/switches and retry buttons.
Template prices freeze at publication. New quotes from a published version keep its product base prices and surcharges. Publishing a new version picks up current catalogue prices.
Admins configure newly booked optional products in the existing accepted-quote add-on amendment screen, including package children. Saving recomputes the offered price, records history, retains another accepted PDF and emails the updated quote. Existing accepted specifications remain read-only; changing one requires reissue. Studio-locked prefilled offers retain their agreed specification while unbooked. Picker instances have independent radio groups.
Existing public/portal quote images remain available with the configurator flag off, behind normal document verification/customer ownership checks.
Migrations
Rebased onto main
191acb36(including #2246 and #2256). The later94acb7bbmain update changes release metadata/version labels only; this branch remains conflict-free. Unreleased migrations are now 320–322, avoiding 316 in #2232, 317–318 in #2233 and 319 in #2249:albumProductsflag and finalproducts.view,products.manage,products.configurepermissions.Existing workflow documentation records these decisions. No additional prose documentation was introduced in this review follow-up.
Validation
Rebase verification on
8830b383: full GitHub CI is green, including the backend/frontend suites, browser smoke tests, AMD64/ARM64 image builds, clean install and upgrade checks. Local rebase checks also passed: 170 affected backend tests, 31 frontend tests, full TypeScript checking, production build and frontend lint with existing warnings. All eleven PR commits were retained. The quote-page conflict was resolved by preserving main's position-based parent handling; migrations 320–322 remain available. There are no unresolved review threads. Draft/merge state is left to the owner and maintainers.Database checks use real core migrations, not stub schemas, and synthetic fixtures.
26 October follow-up (
2e8c6501): 170 SQLite tests across nine suites and 49 PostgreSQL 15 CRM tests pass under Node 22.23.3. Nine new preservation cases first failed one2e5dce6; a tenth positive-control case confirms new catalogue lines and republished templates still adopt current definitions/prices. Backend and touched-test lint pass with one existingno-consolewarning in unrelated source; whitespace and staged credential checks pass. No frontend or migration changes in this follow-up, and no live deployment.Earlier validation (before this backend follow-up):
productConfiguratorCrm(39),albumProducts(13), migration recovery (2), plus 128 related quote/add-on/template/reissue, API/access, backup and privacy checks. Eleven initial regression cases were also run against the original PR head and failed there before the fixes.productConfiguratorCrm(39),albumProducts(13), migration recovery (2). Includes deterministic concurrent duplicate submissions, stamp rollback, financial permissions, public/portal projection and partial-backfill retry.ssh; after installing OpenSSH in the local validation image, all 56 SSH tests passed. Final focused runs above cover subsequent amendment changes.GitHub checks for the updated head are separate from these local results. The PR remains draft for maintainer review.
Screenshots
New accepted-quote add-on booking, with required choices and offered option surcharges:
Phone layout and read-only catalogue
Earlier gallery lock/billing and quote authoring views: