Skip to content

feat: shared product configurator for galleries and CRM (#1924) - #2222

Draft
icelessgo wants to merge 7 commits into
PicPeak:mainfrom
icelessgo:codex/album-product-configurator-1924
Draft

icelessgo wants to merge 7 commits into
PicPeak:mainfrom
icelessgo:codex/album-product-configurator-1924

Conversation

@icelessgo

@icelessgo icelessgo commented Oct 21, 2026 •

Copy link
Copy Markdown

Problem and resulting behaviour

Clients can configure a shared catalogue product from their gallery or while booking an included/optional product on a quote. A quote keeps the offered definition and prices; acceptance freezes its exact specification for contracts and invoices. A later gallery choice becomes a new billable product item rather than changing an accepted quote or issued invoice.

Implements the direction agreed with Luca-Timo in #2222 (comment) and contributes to #1924. The CRM foundation and requested adjustments are now in this same PR, as separate commits. The PR remains draft for upstream review; no production activation, payment collection or supplier ordering is included.

Main changes

  • Shared editable option/image libraries, structured width × height + mm/cm/in presets, basic colours and admin-defined product types. Required, optional and multiple-choice attributes use one picker. v1 dependencies are one prerequisite choice per attribute, with cycle/invalid-reference rejection.
  • CRM catalogue items own base prices and option surcharges. Direct items, packages and published quote templates copy offered definitions into quote lines. Authors can pre-fill partial choices or lock a complete agreed specification. Clients see locked choices read-only; the server rejects changes. Native totals/discounts/VAT apply to the complete configured unit price, with no separate surcharge VAT rate.
  • Locking a gallery choice with CRM billing enabled creates an audited open customer/project product item, priced at lock time. Exactly one compatible unissued, unarmed project invoice gets a distinct frozen line; ambiguous, armed or incompatible invoices leave the item open for review. Gallery/customer controls can create a separate invoice containing only selected open product items. Customer billing remains available after gallery deletion. Multiple assigned customers require an explicit recipient.
  • Gallery price display is an admin toggle, off by default, available only with CRM billing enabled. Prices are indicative until the studio locks the choice. Unbilled unlocks remove the open item; billed choices require native cancellation/Storno, and a live replacement invoice also prevents silent changes. Frozen invoice financial lines cannot be replaced by the editor. A product revision prevents an outdated PDF being issued if a product is appended during rendering.
  • Frozen quote/contract/invoice/billed-item images have durable retention references, backfilled on upgrade. Database deletion guards protect these images on both engines, including SQLite with foreign keys disabled. Existing storage adapters, backup/required-key and portable-transfer inventories include the objects.
  • The remaining archived-gallery privacy blocker is fixed: choice history is deleted before offers, with hasTable guards and a native HTTP regression. Normal/archived deletion erase client names/session digests while audited financial items survive with the gallery link cleared.
  • Native auth, event ownership, gallery expiry/revocation and verified public/portal quote boundaries remain enforced. A gallery assignment does not grant financial controls; billing uses owner/native billing permissions and creator-scoped product-item queries. Generic gallery submissions no longer send empty legacy model/colour fields; partial generic drafts save correctly. Quote edits preserve photographer notes separately from generated specifications.
  • Dimensions/surcharges use DecimalInput; amounts use native money helpers. New controls have English/German translations. Gallery entry remains compact/collapsed inside gallery content. The separate album layout/proofing/export roadmap is not bundled here.

Migration and docs

Migration 316 retains the original storage/disabled albumProducts flag. Additive 317 introduces the generic library and CRM fields and preserves role grants while renaming albums.edit to products.configure. Additive 318 introduces quote locks, gallery price/customer links, audited product billable items, retained image references and the invoice product revision. Upstream main was refreshed before publication; 315 remains its latest core migration.

See setup and client workflows, agreed direction, and CRM integration details.

Validation

All database tests use real core migrations, not a hand-built stub schema.

  • SQLite: 194 tests passed across 15 related suites: CRM/gallery lifecycle and HTTP permissions; quotes, optional products, packages/templates/reissue; contract snapshots/conversion; invoice sending/corrections/accounting history; backup walker, portable roundtrip and recovery inventory.
  • PostgreSQL 15: 19 CRM tests + 13 gallery/domain tests passed, each on a fresh isolated database. Includes archived deletion, native submit audit IDs, open-item billing after deletion, ownership, portal access, Storno/reissue, retained images and concurrent PDF/product changes.
  • Frontend: 16 tests passed across 4 suites for catalogue/picker/client recovery and public quote flows, including locked client presentation and generic draft payloads. Focused product/public-quote ESLint passed. Production frontend bundle built successfully.
  • Synthetic browser → native API: generic client submit, dependent choices, admin lock/open billing item, quote pre-fill/lock/save/reload, customer product panel, light/dark and 390px embedded viewport checks. Screenshots below use synthetic illustrations and example prices only.

Limitations: full TypeScript checking still reports seven existing errors in SelfUpdatePanel, event settings and AttachDocumentDialog; no changed-feature type errors were reported. The bundle build has the existing large-chunk warning. This is related local regression coverage, not a claim that every upstream suite has run. Fork GitHub workflows still require maintainer approval. No real R2 supplier order, payment or physical-print acceptance is claimed.

Screenshots

Uploaded through GitHub from the signed-in PR page; no client photos, supplier artwork or studio prices are included.

Gallery choice locked into an open billable item:

Gallery billing controls

Quote author pre-fill and client lock (expanded for review; collapsed by default):

Quote pre-fill and lock

Dark and phone-sized review previews

Dark gallery controls

390px embedded client viewport

@Luca-Timo

Luca-Timo commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

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 #1924

What 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:

  1. Shared option library instead of per-product lists. Admins maintain standard values once (colours, sizes, frames, materials, papers, …) and products pick from them. Today every product re-types its colours and re-uploads their images.
  2. Standard sizes / formats with editable presets. Sizes should be structured (width × height + unit), not free-text model names. They should ship with a preset set (common photobook and print formats, a basic colour range) that the admin can change, extend or deactivate.
  3. Generic products with their own specs. Albums are one case, not the model for all. A product type declares which attributes it uses and whether each is required, optional or multi-select; the admin defines both the types and the attributes. A new kind of product should be configuration, not code.
  4. Add-ons on a product. Beyond pick-one attributes, a product needs optional extras the customer can select, each with its own options and an optional surcharge. Some will depend on another choice (in the example above, a passe-partout only with a frame).
  5. Works with the CRM catalogue and presets. The same library and products should be usable in the quote catalogue: as a catalogue item or package line, with the option values and presets coming from the same source rather than a parallel list. Prices and per-option surcharges live there.
  6. "Offer as add-on" must be supported. A configured product should work as an optional quote line ("Als Zusatzleistung anbieten") that the customer books on the quote page and configures while booking. The accepted quote then freezes the chosen spec, and contract and invoice lines carry it (for the example above: "Print 90×60, oak frame, passe-partout").

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 direction

Blockers

  1. Migration number collides with feat(projects): new inquiry, and a project page that runs the job #2216. backend/migrations/core/315_album_product_configurator.js and feat(projects): new inquiry, and a project page that runs the job #2216's 315_project_inquiry_fields.js share 315, and feat(projects): new inquiry, and a project page that runs the job #2216 is cleared to merge. Please rebase once it lands and renumber to the next free number, updating anything that cites the path.
  2. Client submit fails on PostgreSQL.
    • backend/src/services/albumProducts/service.js:279-292 inserts into activity_logs directly with actor_id: String(actor.id || '').
    • For a client, actor.id is the 64-hex session digest (routes/gallery/albumProducts.js:67-78), but activity_logs.actor_id is an integer column (src/database/db.js:523).
    • So Postgres rejects the insert, the transaction rolls back, and every client submit returns 500.
    • Neither test run can catch this: SQLite accepts the string, and the domain suite's stub schema declares actor_id as a string (__tests__/integration/albumProducts.test.js:27).
    • Fix: go through logActivity(…, tx), which coerces non-integer ids to null and keeps the name. Run the suite against the real core migrations on Postgres (DATABASE_CLIENT=pg) rather than a hand-built schema.
  3. Client panel renders outside the gallery layout.
    • GalleryView.tsx:1729 and :1960 mount GalleryProductPanel as a sibling before <GalleryLayout>, so it sits above the gallery header (title, Download, Logout).
    • On a phone, the client scrolls about two screens before the first photo. In the Story layout the fixed nav overlaps it.
    • It belongs inside the layout's content area.
  4. Deleting a gallery leaves its choices behind on SQLite.
    • The event delete in routes/adminEvents/helpers.js (~L216-255) removes each child table explicitly, because SQLite runs without PRAGMA foreign_keys.
    • event_album_products and album_product_choice_history aren't on that list, and they hold the client-typed name and session digest. Those outlive the gallery, although docs/album-products.md says deletion cascades.
    • Fix: add hasTable-guarded deletes there (history first), with a test.

Concerns

  1. Buttons have no padding. GalleryProductPanel.tsx uses bare btn btn-primary / btn btn-secondary classes. .btn (index.css:85) carries no size, so "Save draft" and "Submit choice" render edge-to-edge and nearly touch. Use the shared <Button>.
  2. Lock is blocked by any edit to the product, and the client is never told.
    • Lock re-validates against a hash of the whole product (service.js:247-258, hash(product) in validation.js).
    • So editing even the description after a submit makes "Lock choice" fail with "The product options changed" until the client resubmits, while the client still sees "Submitted".
    • Either hash only the chosen options, or tell the client to review their choice.
  3. Submitted → draft is silent. A client re-saving with submit: false moves a submitted choice back to draft with no activity entry (service.js:265, :277). The bell still says "submitted", and a later lock returns 409. Log the retraction or refuse it.
  4. SQLite boolean no-op check. service.js:270 compares current[key] === value. SQLite returns enabled as 1/0, so on SQLite only, re-sending the same value bumps the revision and writes a history row. Normalise with normalizeBoolean and write with formatBoolean.
  5. Unused permissions are seeded and granted. Migration 315 seeds albums.view and albums.export and grants them to admin roles, but nothing references them. Please drop them until a feature uses them.
  6. Only client access sees the picker. The picker appears for the separate client-access link + PIN or an assigned portal account, not for the normal gallery password. That may be the right call. But the portal dashboard and event page never mention the album, so a portal customer has no hint it exists. Worth stating as a decision and adding a pointer in the portal.
  7. Notification is generic. The bell shows "System activity: album product submitted" with no gallery or choice, although the metadata carries both.

UI/UX guide (frontend/STYLING.md, frontend/UX.md)

  • Client picker: options should be selectable tiles with a selected state (UX §6) rather than bare radios. The name field should be <Input themed>. Use border-border-token for borders and rounded-xl / rounded-lg for corners.
  • Admin components: use <Switch> instead of raw checkboxes for Active, and <Input label> / <Button variant="ghost"> instead of raw elements. Show Draft/Submitted/Locked as a <Badge>. Use text-danger-text or <Notice tone="danger"> for errors.
  • Admin page states and actions: add loading, error and empty states (UX §4), keep one primary action per view, and give the product name field a visible label.
  • Page header: use SectionPageHeader feature=… so the experimental badge shows. Drop albumProducts from TABS_WITH_OWN_HEADER and add it to TAB_FEATURE.
  • Read-only access: products.view holders get a bare line instead of a read-only catalogue (UX §3).
  • Invalid token: ProductImage.tsx:17 uses bg-panel-subtle, which isn't a token.
  • Small cleanups: model.ts:44 hard-codes 'Please try again.', and EventProductsCard fetches with useState/useEffect instead of TanStack Query.
  • i18n: common.name, common.description, albumProducts.featureTitle and albumProducts.featureDescription are missing from both locales. The common.retry fallback says "Try again" where en.json says "Retry".

Migrations

  • The 109 edit: the album-products row added to 109_add_backup_paths.js follows the existing pattern, since boot reseeds defaults from 109. That makes the backup_paths insert in 315 redundant; keep one of them.
  • Missing guard: in 315, guard the roles query with hasTable.
  • down(): in 315, remove the backup_paths row.

PR body

  • Test counts: the body says "54 focused backend checks", "9 Postgres" and "10 frontend checks". I count 14 tests in the two new backend suites (44 including the touched walker suite), 10 in the domain suite and 4 frontend tests. Please list the suites with their counts, and say that the Postgres run used a stub schema.
  • Unstated decisions: please name them in the body: migration 315 (four tables, a flag row, a backup path), the 109 edit, the new albums permission category, and where the client panel mounts.

Happy to talk through the direction on #1924 before you put more time into this shape.

@icelessgo

Copy link
Copy Markdown
Author

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:

  • One admin-maintained option library (including images and structured width/height/unit sizes), editable/deactivatable presets, and configurable product types. Product attributes reference library values; each declares required/optional and single/multiple selection. Products can include optional add-ons with options and acyclic dependencies on other choices. No album-specific assumptions in the new picker or validation.
  • The CRM catalogue item references that product and owns its base price and option/add-on surcharges. Package lines and quote presets reuse the same reference. Gallery choices do not require CRM activation and do not charge a customer.
  • Quotes copy the offered configuration definition and CRM prices into their line when authored. Customers configure both included and optional lines on the existing verified quote page; only booked lines require a complete configuration. The server validates dependencies, recomputes all surcharges and the quote total, and checks the total the customer confirmed.
  • Acceptance atomically freezes the selected specification (IDs, labels, dimensions, images, and price breakdown) on each booked line, using the same selection snapshot as the gallery. Contracts and invoices carry that frozen specification. Later library/price edits never rewrite sent offers or accepted documents. The existing response-window and conversion locks remain authoritative.
  • The gallery picker remains restricted to primary-client/assigned portal access, within gallery content, with a portal pointer. Gallery drafts/submissions/photographer locks remain separate from a legally accepted CRM specification; changing one cannot silently amend the other.

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.

@icelessgo
icelessgo force-pushed the codex/album-product-configurator-1924 branch from 14d09f3 to 389447c Compare October 22, 2026 07:54
@icelessgo

Copy link
Copy Markdown
Author

The four blockers are fixed in 389447c, rebased onto fb6702f (including the inquiry change):

  • Renumbered to 316_product_configurator.js; removed unused view/export permissions, redundant backup-path insertion, added role-table guards and rollback cleanup.
  • Client submission uses native logActivity(..., tx) with the real integer actor column. The domain suite now boots all core migrations rather than a hand-built schema.
  • The normal gallery entry is inside GalleryLayout; full-page layouts place it after their photo content. The picker is a compact collapsed entry until opened. Main actions use shared Buttons and a themed Input, with selected tiles.
  • Native gallery deletion explicitly removes choice history then offers, including names/session digests when SQLite foreign keys are off.

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: albumProducts.test.js 12 tests with the actual core migrations on SQLite and PostgreSQL (DATABASE_CLIENT=pg); albumProducts.access.test.js 4 tests on the real SQLite schema. The deletion test also confirms a different gallery's history survives. This replaces the earlier PostgreSQL result based on a stub schema.

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.

@icelessgo

Copy link
Copy Markdown
Author

Updated client picker after the blocker fixes (synthetic illustrations and client name only). The entry is collapsed until opened; selected tiles, padded shared buttons and the themed name input are shown here.

picpeak-configurator-blocker-client

@Luca-Timo

Copy link
Copy Markdown
Collaborator

Thanks for the quick turnaround! I checked out 1b995a8d4 and went through the blocker commit:

  • Migration: renumbered to 316 after the feat(projects): new inquiry, and a project page that runs the job #2216 merge. Unused permissions are dropped, the roles guard is in, and the down() cleanup works.
  • Activity log: submit now goes through logActivity(…, tx).
  • Gallery layout: the panel is inside the gallery content, and the picker has selected tiles and padded buttons (thanks for the screenshot).
  • Gallery deletion: removes choices and history in deleteClaimedEvent.
  • Lock / draft / no-op: the lock re-check only covers the chosen options, a submitted choice can no longer silently return to draft, and repeating a boolean no longer bumps the revision.

I also ran albumProducts.test.js on PostgreSQL 15 with the real core migrations (12/12) and both suites on SQLite (16/16), and usageCoverageInventory still passes. Nice work.

One remaining gap of the same kind as blocker 4:

  • Deleting an archived gallery skips the new tables. backend/src/routes/adminArchives.js (~L986-1021) has its own delete transaction. It clears selection_submissions and the other child tables explicitly, but not album_product_choice_history / event_album_products. On SQLite (foreign keys off), deleting an archived gallery therefore still leaves the client names and session digests behind. Please add the same hasTable-guarded deletes there, with a test like the one for deleteClaimedEvent.

On the shared design

Yes, 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. docs/product-configurator-direction.md can drop "confirmation pending". A few additions and adjustments:

  1. Gallery choices should become billable. In practice the gallery usually exists after the quote is accepted and locked, so a choice made there can't go back into the quote. When the photographer locks a gallery choice (and the CRM is enabled), it should become an open billable item for that customer/project, priced from the CRM catalogue at lock time.

    • This works the same way open hour entries and re-bills already do (customerHoursService, combinedBillingService: unbilled → stamped with its invoice line).
    • If the project's invoice hasn't been issued yet, the item goes onto it as its own line with the frozen spec.
    • If that invoice is already issued (the usual case, since the gallery comes after the job), the photographer bills the open product items as a separate, second invoice for just those items.
    • An issued invoice is never changed. If the choice is unlocked before it is billed, the open item goes away with it. Once it's billed, a change goes through the normal correction path (Storno), not a silent edit.

    This keeps your boundary intact, since a gallery lock never amends an accepted document; it creates a new billable item instead.

  2. Prices in the gallery picker: an admin toggle. Off by default, the picker shows options only. When it's on (and the CRM is enabled), it shows base price and surcharges from the CRM catalogue, as information only. The binding price is the one on the open item at lock time.

  3. Pre-filled configuration on quote lines. When authoring a quote, the photographer can pre-fill a line's configuration and lock it, for a spec already agreed with the client (e.g. "album 30×30, linen"). The customer then sees it read-only. Unlocked or partly filled lines stay customer-configurable, as you proposed.

  4. VAT on surcharges follows the line's VAT code from the shared vat_codes registry, not a separate rate.

  5. Images referenced by frozen specs (accepted quotes, contracts, invoices, billed items) must never be removed, including by any later cleanup of unused images.

  6. Keep dependencies simple for v1. "Option B is only available with option A" covers the real cases. Rejecting cycles is fine, but a general dependency graph doesn't need to be built yet.

  7. Inputs and money: width/height and price fields need to accept German decimal commas (parseLocaleDecimal / DecimalInput), and amounts should go through the existing money helpers (formatMoneyMinor, utils/money).

  8. One PR, please. When the CRM part on codex/shared-product-configurator-crm is ready, bring it into this PR instead of opening a separate one. It's easier to review the configurator as a whole, and keeping it in separate commits as you planned works well for that.

Thanks again for taking this on!

@icelessgo icelessgo changed the title feat: native gallery album product choices (#1924) feat: shared product configurator for galleries and CRM (#1924) Oct 22, 2026

Copy link
Copy Markdown
Author

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. codex/shared-product-configurator-crm is now a development/reference branch with the same final file contents; no second PR is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants