feat: favorite items and notify when a favorite is sold (#34)
Customers can favorite and unfavorite items from the storefront, opt in to being told when a favorite is sold to someone else, and manage that preference from their account page. The opt-in is a consent of its own rather than the existing marketing flag. Being told that a specific item you asked about has gone is a narrower thing than agreeing to marketing, and folding one into the other would leave marketing_consent_text no longer describing what was actually agreed to. It is recorded the same way as the marketing consent — flag, timestamp, and the exact wording shown — and accepting it does not set marketing_consent. The prompt appears only after a customer has actually favorited something, so the reason for asking is concrete rather than an abstract marketing ask, and it says plainly that it is separate from marketing email. Declining keeps the favorite. Notifications fire when an item reaches sold, either through checkout or an admin marking it sold, and never to the buyer — telling someone the item they just bought is unavailable reads as a bug. Reserved is deliberately not a trigger: reservations expire and get released, so a "gone" email would often be about an item still for sale. Disabled accounts are excluded, per #33. completeCheckout now returns the sold item ids and the buyer so its three call sites can notify after COMMIT. Sending inside the transaction would email people about a sale that then rolled back, and would hold the transaction open for SMTP. Each message is sent independently so one bad address cannot stop the rest, and the sale has already succeeded regardless. Favoriting while signed out opens the existing inline register/login modal, exactly as Add to Cart does, and completes the favorite on success. Also fixes a latent bug in the same component: while the session was still resolving, `customer` is null for a signed-in visitor too, so clicking Add to Cart or the new heart in that window prompted them to sign in again. Both now ignore clicks until the session has resolved, and the control shows as loading meanwhile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { pool } from './db';
|
||||
import { sendMail } from './mailer';
|
||||
|
||||
// Shown to the customer when they opt in, and stored verbatim against their
|
||||
// consent so the record says what they actually agreed to — the same pattern
|
||||
// the marketing consent already uses.
|
||||
export const FAVORITE_ALERTS_CONSENT_TEXT =
|
||||
'Email me when an item I have favorited is sold to someone else, so I know it is no longer available.';
|
||||
|
||||
interface Recipient {
|
||||
email: string;
|
||||
item_name: string;
|
||||
}
|
||||
|
||||
// Called *after* the sale has been committed, never inside the transaction.
|
||||
// Emailing about a sale that then rolled back would be worse than a late
|
||||
// notification, and the transaction should not be held open for SMTP.
|
||||
//
|
||||
// `buyerId` is excluded: telling customers the item they just bought is no
|
||||
// longer available reads as a bug.
|
||||
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise<void> {
|
||||
if (!itemIds.length) return;
|
||||
|
||||
const { rows } = await pool.query<Recipient>(
|
||||
`SELECT c.email, i.name AS item_name
|
||||
FROM favorites f
|
||||
JOIN customers c ON c.id = f.customer_id
|
||||
JOIN items i ON i.id = f.item_id
|
||||
WHERE f.item_id = ANY($1::int[])
|
||||
AND c.favorite_alerts = true
|
||||
AND c.disabled_at IS NULL
|
||||
AND ($2::int IS NULL OR c.id <> $2::int)`,
|
||||
[itemIds, buyerId]
|
||||
);
|
||||
|
||||
for (const recipient of rows) {
|
||||
// Sent one at a time and independently: one failed address must not stop
|
||||
// the rest, and the sale itself has already succeeded regardless.
|
||||
sendMail(
|
||||
recipient.email,
|
||||
`"${recipient.item_name}" has been sold`,
|
||||
`<p>An item you favorited has been sold to another customer, so it is no longer available.</p>
|
||||
<p><b>${recipient.item_name}</b></p>
|
||||
<p>Every piece is one of a kind, so this one will not be restocked. You can browse what is
|
||||
still available at <a href="${process.env.PUBLIC_URL}">Redefined Designs</a>.</p>
|
||||
<p>You are receiving this because you asked to be told when a favorited item is sold. You can
|
||||
turn these off on your account page.</p>`
|
||||
).catch(err => console.error('favorite sold notification failed', err));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||
import { tagColorFor } from '../utils';
|
||||
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -243,6 +244,9 @@ router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
|
||||
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
|
||||
[req.params.id]
|
||||
);
|
||||
// No buyer to exclude: an admin marking an item sold has no associated
|
||||
// customer, so everyone watching it hears about it.
|
||||
await notifyFavoritersOfSale([Number(req.params.id)], null);
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
||||
|
||||
const router = Router();
|
||||
const webhookRouter = Router();
|
||||
@@ -140,7 +141,10 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown) {
|
||||
// Returns the sold item ids and the buyer, so the caller can notify favoriters
|
||||
// *after* COMMIT. Sending inside the transaction would email people about a
|
||||
// sale that then rolled back, and would hold the transaction open for SMTP.
|
||||
async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
|
||||
const { rows: checkoutItems } = await client.query(
|
||||
`SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`,
|
||||
[checkoutId]
|
||||
@@ -158,6 +162,11 @@ async function completeCheckout(client: any, checkoutId: number, processor: stri
|
||||
await client.query(`DELETE FROM cart_items WHERE item_id = $1`, [ci.item_id]);
|
||||
}
|
||||
await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]);
|
||||
|
||||
return {
|
||||
itemIds: checkoutItems.map((ci: { item_id: number }) => ci.item_id),
|
||||
buyerId: customerId ?? null
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => {
|
||||
@@ -181,8 +190,9 @@ router.post('/paypal/capture', requireCustomer, async (req: Request, res: Respon
|
||||
if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
|
||||
|
||||
await client.query('BEGIN');
|
||||
await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
|
||||
const sold = await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
|
||||
await client.query('COMMIT');
|
||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
||||
res.json({ status: 'completed' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -204,8 +214,9 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons
|
||||
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
|
||||
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
||||
|
||||
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
||||
const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
||||
await client.query('COMMIT');
|
||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
||||
res.json({ status: 'completed' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -244,8 +255,9 @@ webhookRouter.post('/', async (req: Request, res: Response) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event);
|
||||
const sold = await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event);
|
||||
await client.query('COMMIT');
|
||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('webhook completeCheckout failed', e);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pool } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { sendMail } from '../mailer';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { passwordResetRequestLimiter } from '../rateLimit';
|
||||
|
||||
@@ -38,6 +39,7 @@ function publicCustomer(c: any) {
|
||||
name: c.name,
|
||||
email_verified: c.email_verified,
|
||||
marketing_consent: c.marketing_consent,
|
||||
favorite_alerts: c.favorite_alerts,
|
||||
created_at: c.created_at
|
||||
};
|
||||
}
|
||||
@@ -218,6 +220,51 @@ router.post('/logout', async (req: Request, res: Response) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT f.item_id, f.created_at, i.name, i.status
|
||||
FROM favorites f JOIN items i ON i.id = f.item_id
|
||||
WHERE f.customer_id = $1
|
||||
ORDER BY f.created_at DESC`,
|
||||
[req.customerId]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.post('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: item } = await pool.query(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
|
||||
if (!item.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// Idempotent: a double click, or two tabs, must not be an error.
|
||||
await pool.query(
|
||||
`INSERT INTO favorites (customer_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[req.customerId, req.params.itemId]
|
||||
);
|
||||
res.status(201).json({ item_id: Number(req.params.itemId) });
|
||||
}));
|
||||
|
||||
router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
await pool.query(`DELETE FROM favorites WHERE customer_id = $1 AND item_id = $2`,
|
||||
[req.customerId, req.params.itemId]);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
// A consent of its own, deliberately not the marketing flag. Recorded the same
|
||||
// way as the marketing consent — flag, timestamp, and the exact wording shown —
|
||||
// so the record says what was actually agreed to.
|
||||
router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const enabled = !!req.body?.enabled;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE customers
|
||||
SET favorite_alerts = $1,
|
||||
favorite_alerts_at = $2,
|
||||
favorite_alerts_text = $3
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(rows[0]));
|
||||
}));
|
||||
|
||||
router.get('/me', requireCustomer, async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
Reference in New Issue
Block a user