Favorite items, with notification when a favorite sells #34

Closed
opened 2026-08-17 17:09:53 -05:00 by bermudalamb · 3 comments
Owner

Split out of #31 (item 4).

Goal

A customer can favorite and unfavorite an item. When a favorited item sells to someone else, a customer who has opted in to email is notified that it is no longer available. A customer who has not opted in is offered the opt-in, with the reason stated.

Scope

  • New table linking customers to items
  • Favorite/unfavorite from the storefront
  • A hook in the sale path that notifies favoriters when an item becomes sold
  • Consent-prompt UX for customers who have not opted in to marketing email

Why the consent prompt needs care

The existing GDPR handling is deliberate: marketing consent is never pre-checked, is timestamped with the exact consent text shown, and is separately revocable. This feature asks a customer to opt in for a specific reason — "so we can tell you if this sells" — which is arguably a different purpose from the existing marketing consent.

That needs deciding rather than assuming: is this the same consent flag, or a second, narrower one? Bundling a transactional notification into a marketing opt-in is the kind of thing that undermines the clean consent record already in place.

Design questions to resolve before implementing

  • Same marketing_consent flag, or a separate notification preference?
  • Does the notification fire when the favoriter themselves buys the item? (It should not.)
  • Does it fire on a demo-mode purchase, and on an admin "Mark Sold"?
  • Should favoriting require an account, or work anonymously until checkout the way add-to-cart does?
  • What happens to favorites when an item is deleted by an admin?
  • Is the email one-per-item, or batched?

Related

Filtering the storefront by favorites (#35) depends on this.

Split out of #31 (item 4). ## Goal A customer can favorite and unfavorite an item. When a favorited item sells to someone else, a customer who has opted in to email is notified that it is no longer available. A customer who has not opted in is offered the opt-in, with the reason stated. ## Scope - New table linking customers to items - Favorite/unfavorite from the storefront - A hook in the sale path that notifies favoriters when an item becomes `sold` - Consent-prompt UX for customers who have not opted in to marketing email ## Why the consent prompt needs care The existing GDPR handling is deliberate: marketing consent is never pre-checked, is timestamped with the exact consent text shown, and is separately revocable. This feature asks a customer to opt in for a *specific* reason — "so we can tell you if this sells" — which is arguably a different purpose from the existing marketing consent. That needs deciding rather than assuming: is this the same consent flag, or a second, narrower one? Bundling a transactional notification into a marketing opt-in is the kind of thing that undermines the clean consent record already in place. ## Design questions to resolve before implementing - Same `marketing_consent` flag, or a separate notification preference? - Does the notification fire when the favoriter themselves buys the item? (It should not.) - Does it fire on a demo-mode purchase, and on an admin "Mark Sold"? - Should favoriting require an account, or work anonymously until checkout the way add-to-cart does? - What happens to favorites when an item is deleted by an admin? - Is the email one-per-item, or batched? ## Related Filtering the storefront by favorites (#35) depends on this.
bermudalamb added this to the Initial Build project 2026-08-17 17:21:05 -05:00
bermudalamb self-assigned this 2026-08-17 17:22:10 -05:00
bermudalamb moved this to In Progress in Initial Build on 2026-08-17 18:56:00 -05:00
bermudalamb moved this to Ready for Review in Initial Build on 2026-08-17 18:56:26 -05:00
bermudalamb moved this to Send back in Initial Build on 2026-08-18 10:25:05 -05:00
Author
Owner

Implemented

Branch feature/favorites, one commit, not yet pushed. Includes a migration, applied automatically on container start.

Decisions taken

Question Answer
What consent are we asking for A separate, narrower onefavorite_alerts, recorded with its own flag, timestamp, and the exact wording shown
What counts as "no longer available" Sold only, to someone else — via checkout or an admin marking it sold
Signed-out visitors Prompted to sign in, then the favorite completes

Why a separate consent

Being told that a specific item you asked about has gone is a narrower thing than agreeing to marketing. Reusing marketing_consent would have left marketing_consent_text no longer describing what the customer actually agreed to, and would sign someone up for campaigns when all they wanted was to hear about one table. Accepting item alerts leaves marketing_consent untouched, and there is an integration test asserting exactly that.

Why not notify on reserve

Reservations expire and get released, so a "this has gone" email would frequently be about an item that is still for sale. A last-chance warning that turns out to be wrong is worse than silence.

Why the buyer is excluded

Emailing a customer to say the item they just bought is unavailable reads as a bug. Their own purchase never triggers a notification to them, though other favoriters are still told.

Notification timing

completeCheckout now returns the sold item ids and the buyer, so its three call sites — PayPal capture, demo purchase, and the PayPal webhook — notify after COMMIT. Sending inside the transaction would email people about a sale that then rolled back, and would hold a database transaction open for the duration of SMTP. Each message is sent independently, so one bad address cannot stop the rest, and the sale has already succeeded regardless of whether mail goes out.

Disabled accounts are excluded, honouring the rule established in #33.

The prompt

It appears only after a customer has actually favorited something, so the reason for asking is concrete rather than an abstract marketing ask. It states plainly that it is separate from marketing email, and declining still keeps the favorite. The preference is manageable afterwards from the account page, alongside but distinct from the marketing toggle.

A latent bug fixed alongside

While the session is still resolving, customer is null for a signed-in visitor too. Clicking Add to Cart in that window prompted an already-signed-in customer to sign in again. Both that button and the new heart now ignore clicks until the session has resolved, and show as loading meanwhile. The favorites e2e caught this because it navigates and clicks immediately; a human would rarely be fast enough to hit it, but it was real.

Verification

Suite Result
Backend unit 52 passed
Backend integration 121 passed (17 new)
Frontend e2e 63 passed (6 new), stable over two consecutive runs
tsc --noEmit backend / frontend clean
npm run build clean

Integration coverage includes idempotent favoriting, per-customer isolation, the consent being independent of marketing, and every notification rule: opted-in favoriters notified, non-opted-in not, the buyer excluded, multiple favoriters each notified, admin mark-sold notifying, and disabled accounts skipped.

Next

#35 (filter the storefront by favorites) is now unblocked — the favorites table and the shared FavoritesContext it needs both exist.

## Implemented Branch `feature/favorites`, one commit, not yet pushed. Includes a migration, applied automatically on container start. ## Decisions taken | Question | Answer | | --- | --- | | What consent are we asking for | **A separate, narrower one** — `favorite_alerts`, recorded with its own flag, timestamp, and the exact wording shown | | What counts as "no longer available" | **Sold only, to someone else** — via checkout or an admin marking it sold | | Signed-out visitors | **Prompted to sign in**, then the favorite completes | ### Why a separate consent Being told that a specific item you asked about has gone is a narrower thing than agreeing to marketing. Reusing `marketing_consent` would have left `marketing_consent_text` no longer describing what the customer actually agreed to, and would sign someone up for campaigns when all they wanted was to hear about one table. Accepting item alerts leaves `marketing_consent` untouched, and there is an integration test asserting exactly that. ### Why not notify on reserve Reservations expire and get released, so a "this has gone" email would frequently be about an item that is still for sale. A last-chance warning that turns out to be wrong is worse than silence. ### Why the buyer is excluded Emailing a customer to say the item they just bought is unavailable reads as a bug. Their own purchase never triggers a notification to them, though other favoriters are still told. ## Notification timing `completeCheckout` now returns the sold item ids and the buyer, so its three call sites — PayPal capture, demo purchase, and the PayPal webhook — notify **after** `COMMIT`. Sending inside the transaction would email people about a sale that then rolled back, and would hold a database transaction open for the duration of SMTP. Each message is sent independently, so one bad address cannot stop the rest, and the sale has already succeeded regardless of whether mail goes out. Disabled accounts are excluded, honouring the rule established in #33. ## The prompt It appears only after a customer has actually favorited something, so the reason for asking is concrete rather than an abstract marketing ask. It states plainly that it is separate from marketing email, and declining still keeps the favorite. The preference is manageable afterwards from the account page, alongside but distinct from the marketing toggle. ## A latent bug fixed alongside While the session is still resolving, `customer` is null for a signed-in visitor too. Clicking **Add to Cart** in that window prompted an already-signed-in customer to sign in again. Both that button and the new heart now ignore clicks until the session has resolved, and show as loading meanwhile. The favorites e2e caught this because it navigates and clicks immediately; a human would rarely be fast enough to hit it, but it was real. ## Verification | Suite | Result | | --- | --- | | Backend unit | 52 passed | | Backend integration | 121 passed (17 new) | | Frontend e2e | 63 passed (6 new), stable over two consecutive runs | | `tsc --noEmit` backend / frontend | clean | | `npm run build` | clean | Integration coverage includes idempotent favoriting, per-customer isolation, the consent being independent of marketing, and every notification rule: opted-in favoriters notified, non-opted-in not, the buyer excluded, multiple favoriters each notified, admin mark-sold notifying, and disabled accounts skipped. ## Next #35 (filter the storefront by favorites) is now unblocked — the `favorites` table and the shared `FavoritesContext` it needs both exist.
bermudalamb moved this to In Progress in Initial Build on 2026-08-18 13:53:44 -05:00
bermudalamb moved this to Ready for Review in Initial Build on 2026-08-18 14:08:13 -05:00
bermudalamb moved this to Review in Initial Build on 2026-08-18 14:08:20 -05:00
Author
Owner

Design answers checked against the implementation

Five of the six were already built as specified. One was a genuine gap and is now done.

Question Answer Status
Same marketing_consent, or separate? Separate notification preference Already built — favorite_alerts with its own flag, timestamp and consent text, with a test asserting it does not touch marketing_consent
Fire when the favoriter buys it themselves? It should not Already built — the buyer is excluded, other favoriters are still told
Fire on demo purchase and admin Mark Sold? Both Already built — demo goes through completeCheckout, Mark Sold is wired directly, both tested
Favoriting anonymous, or require an account? Require an account Already built — inline sign-in prompt, and requireCustomer server-side
One email per item, or batched? One per item Already built
What happens when an admin deletes an item? Email that it is no longer available, if opted in Was missing — now implemented

The gap

favorites.item_id is ON DELETE CASCADE, so deleting an item took its favorites with it and nobody was told. Recipients are now collected before the delete — afterwards there is nobody left to look up — and the mail is sent only once the delete has succeeded, so nobody hears about a withdrawal that did not happen.

Two judgement calls in that, worth flagging in case either is wrong:

  • Items that had already sold are excluded. Their favoriters were told at the point of sale, and a second message about the same item reads as a duplicate rather than news. Deleting a sold item is therefore silent.
  • The wording differs from the sale email — "withdrawn and is no longer available" rather than "sold". The customer did not lose out to another buyer, and saying they had would be untrue.

Disabled accounts are excluded here too, consistent with everything else.

Verification

Suite Result
Backend unit 52 passed
Backend integration 126 passed (5 new for withdrawal)
Frontend e2e 63 passed, two consecutive clean runs
tsc --noEmit backend / frontend clean

New coverage: opted-in favoriters told on delete, non-opted-in not told, an already-sold item not producing a second message, disabled accounts skipped, and deletion still working when nobody favorited the item.

One note on the run: a single storefront filter test failed once during the sweep and then passed 36/36 on a focused repeat and across two further full runs. I could not reproduce it and do not believe it relates to this change, but recording it rather than quietly discarding it.

## Design answers checked against the implementation Five of the six were already built as specified. One was a genuine gap and is now done. | Question | Answer | Status | | --- | --- | --- | | Same `marketing_consent`, or separate? | Separate notification preference | Already built — `favorite_alerts` with its own flag, timestamp and consent text, with a test asserting it does not touch `marketing_consent` | | Fire when the favoriter buys it themselves? | It should not | Already built — the buyer is excluded, other favoriters are still told | | Fire on demo purchase and admin Mark Sold? | Both | Already built — demo goes through `completeCheckout`, Mark Sold is wired directly, both tested | | Favoriting anonymous, or require an account? | Require an account | Already built — inline sign-in prompt, and `requireCustomer` server-side | | One email per item, or batched? | One per item | Already built | | **What happens when an admin deletes an item?** | **Email that it is no longer available, if opted in** | **Was missing — now implemented** | ## The gap `favorites.item_id` is `ON DELETE CASCADE`, so deleting an item took its favorites with it and nobody was told. Recipients are now collected **before** the delete — afterwards there is nobody left to look up — and the mail is sent only once the delete has succeeded, so nobody hears about a withdrawal that did not happen. Two judgement calls in that, worth flagging in case either is wrong: - **Items that had already sold are excluded.** Their favoriters were told at the point of sale, and a second message about the same item reads as a duplicate rather than news. Deleting a sold item is therefore silent. - **The wording differs from the sale email** — "withdrawn and is no longer available" rather than "sold". The customer did not lose out to another buyer, and saying they had would be untrue. Disabled accounts are excluded here too, consistent with everything else. ## Verification | Suite | Result | | --- | --- | | Backend unit | 52 passed | | Backend integration | 126 passed (5 new for withdrawal) | | Frontend e2e | 63 passed, two consecutive clean runs | | `tsc --noEmit` backend / frontend | clean | New coverage: opted-in favoriters told on delete, non-opted-in not told, an already-sold item not producing a second message, disabled accounts skipped, and deletion still working when nobody favorited the item. One note on the run: a single storefront filter test failed once during the sweep and then passed 36/36 on a focused repeat and across two further full runs. I could not reproduce it and do not believe it relates to this change, but recording it rather than quietly discarding it.
bermudalamb moved this to Ready for Release in Initial Build on 2026-08-18 16:50:53 -05:00
Author
Owner

Released to production.

Reviewed in QA first: the heart prompts a signed-out visitor to register and applies the favorite afterwards, the alert opt-in is offered separately from marketing consent, and the account page shows the two switches independently. All passed.

One caveat worth leaving on the record: the emails themselves could not be exercised in QA, which runs without SMTP by design. Production is the first place notifyFavoritersOfSale and notifyFavoritersOfRemoval actually send. Sends are caught per-recipient and only logged, so a misconfiguration would leave sales working normally while nobody is notified — the failure would be silent. Worth one deliberate smoke test in production: favorite an item from a test account with alerts on, mark it sold in admin, confirm the mail arrives, and confirm the buyer is not mailed about their own purchase.

The schema change (add-favorites) is additive and idempotent, so the previous image runs unchanged against the migrated schema if a rollback is ever needed.

**Released to production.** Reviewed in QA first: the heart prompts a signed-out visitor to register and applies the favorite afterwards, the alert opt-in is offered separately from marketing consent, and the account page shows the two switches independently. All passed. One caveat worth leaving on the record: **the emails themselves could not be exercised in QA**, which runs without SMTP by design. Production is the first place `notifyFavoritersOfSale` and `notifyFavoritersOfRemoval` actually send. Sends are caught per-recipient and only logged, so a misconfiguration would leave sales working normally while nobody is notified — the failure would be silent. Worth one deliberate smoke test in production: favorite an item from a test account with alerts on, mark it sold in admin, confirm the mail arrives, and confirm the buyer is *not* mailed about their own purchase. The schema change (`add-favorites`) is additive and idempotent, so the previous image runs unchanged against the migrated schema if a rollback is ever needed.
bermudalamb moved this to Released in Initial Build on 2026-08-19 10:42:05 -05:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#34