diff --git a/backend/src/favoriteAlerts.ts b/backend/src/favoriteAlerts.ts index f252f12..227bedb 100644 --- a/backend/src/favoriteAlerts.ts +++ b/backend/src/favoriteAlerts.ts @@ -7,11 +7,51 @@ import { sendMail } from './mailer'; 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 { +export interface FavoriteRecipient { email: string; item_name: string; } +// Gathered separately from sending because deleting an item cascades its +// favorites away: the recipients have to be read *before* the row goes, while +// the send has to happen *after*, so nobody is told about a withdrawal that +// then failed. +export async function collectFavoriteRecipients( + itemIds: number[], + excludeCustomerId: number | null, + onlyUnsold = false +): Promise { + if (!itemIds.length) return []; + + const { rows } = await pool.query( + `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) + AND ($3::boolean = false OR i.status <> 'sold')`, + [itemIds, excludeCustomerId, onlyUnsold] + ); + return rows; +} + +// One message per item per person, never batched: each is about a specific +// thing the customer asked to hear about. Sent independently so one bad +// address cannot stop the rest — and whatever prompted this has already +// happened regardless of whether the mail goes out. +function send(recipients: FavoriteRecipient[], subject: (name: string) => string, body: (name: string) => string): void { + for (const recipient of recipients) { + sendMail(recipient.email, subject(recipient.item_name), body(recipient.item_name)) + .catch(err => console.error('favorite alert failed', err)); + } +} + +const FOOTER = `

You are receiving this because you asked to be told when a favorited item becomes + unavailable. You can turn these off on your account page.

`; + // 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. @@ -19,32 +59,28 @@ interface Recipient { // `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 { - if (!itemIds.length) return; - - const { rows } = await pool.query( - `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`, - `

An item you favorited has been sold to another customer, so it is no longer available.

-

${recipient.item_name}

+ const recipients = await collectFavoriteRecipients(itemIds, buyerId); + send( + recipients, + name => `"${name}" has been sold`, + name => `

An item you favorited has been sold to another customer, so it is no longer available.

+

${name}

Every piece is one of a kind, so this one will not be restocked. You can browse what is still available at Redefined Designs.

-

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.

` - ).catch(err => console.error('favorite sold notification failed', err)); - } + ${FOOTER}` + ); +} + +// Sent when an item is withdrawn from sale rather than sold. Recipients must be +// collected before the delete, since the favorites rows cascade with the item. +export function notifyFavoritersOfRemoval(recipients: FavoriteRecipient[]): void { + send( + recipients, + name => `"${name}" is no longer available`, + name => `

An item you favorited has been withdrawn and is no longer available.

+

${name}

+

You can browse what is still available at + Redefined Designs.

+ ${FOOTER}` + ); } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 6ade4fa..cd16d85 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -8,7 +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'; +import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts'; const router = Router(); @@ -229,10 +229,21 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { } }); -router.delete('/items/:id', async (req: Request, res: Response) => { - await pool.query(`DELETE FROM items WHERE id = $1`, [req.params.id]); +router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => { + const itemId = Number(req.params.id); + + // Collected before the delete: favorites cascade with the item, so after it + // is gone there is no record of who was watching. Restricted to unsold items + // because anyone watching a sold one has already been told it went. + const recipients = await collectFavoriteRecipients([itemId], null, true); + + await pool.query(`DELETE FROM items WHERE id = $1`, [itemId]); + + // Sent only once the delete has succeeded, so nobody hears about a withdrawal + // that did not happen. + notifyFavoritersOfRemoval(recipients); res.status(204).end(); -}); +})); router.delete('/items/:id/images/:imageId', async (req: Request, res: Response) => { await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]); diff --git a/backend/tests/integration/favorites.integration.test.ts b/backend/tests/integration/favorites.integration.test.ts index 37fabba..0a020fa 100644 --- a/backend/tests/integration/favorites.integration.test.ts +++ b/backend/tests/integration/favorites.integration.test.ts @@ -43,6 +43,13 @@ function soldNotificationsTo(): string[] { .map(call => String(call[0])); } +// Recipients of the "no longer available" mail sent when an item is withdrawn. +function removalNotificationsTo(): string[] { + return sentMail.mock.calls + .filter(call => String(call[1]).includes('no longer available')) + .map(call => String(call[0])); +} + describe('favoriting items', () => { it('records a favorite and lists it back', async () => { const itemId = await createItem('Oak table'); @@ -240,3 +247,60 @@ describe('notifying when a favorited item sells', () => { expect(soldNotificationsTo()).toEqual([]); }); }); + +describe('notifying when a favorited item is deleted', () => { + it('emails opted-in favoriters that it is no longer available', async () => { + const itemId = await createItem('Withdrawn item'); + const { agent } = await register('withdrawn@example.com'); + await agent.post(`/api/customers/me/favorites/${itemId}`); + await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true }); + + expect((await request(app).delete(`/api/admin/items/${itemId}`)).status).toBe(204); + + expect(removalNotificationsTo()).toEqual(['withdrawn@example.com']); + }); + + it('does not email a favoriter who never opted in', async () => { + const itemId = await createItem('Withdrawn item'); + const { agent } = await register('quiet@example.com'); + await agent.post(`/api/customers/me/favorites/${itemId}`); + + await request(app).delete(`/api/admin/items/${itemId}`); + + expect(removalNotificationsTo()).toEqual([]); + }); + + it('does not tell them twice when the item had already sold', async () => { + const itemId = await createItem('Already sold'); + const { agent } = await register('twice-told@example.com'); + await agent.post(`/api/customers/me/favorites/${itemId}`); + await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true }); + + await request(app).post(`/api/admin/items/${itemId}/mark-sold`); + expect(soldNotificationsTo()).toEqual(['twice-told@example.com']); + + // They already know it has gone; deleting the record should not say so again. + await request(app).delete(`/api/admin/items/${itemId}`); + expect(removalNotificationsTo()).toEqual([]); + }); + + it('does not notify a disabled customer', async () => { + const itemId = await createItem('Withdrawn item'); + const { agent, id } = await register('disabled-del@example.com'); + await agent.post(`/api/customers/me/favorites/${itemId}`); + await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true }); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + await request(app).delete(`/api/admin/items/${itemId}`); + + expect(removalNotificationsTo()).toEqual([]); + }); + + it('still deletes the item when nobody favorited it', async () => { + const itemId = await createItem('Unloved'); + expect((await request(app).delete(`/api/admin/items/${itemId}`)).status).toBe(204); + + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM items WHERE id = $1`, [itemId]); + expect(rows[0].n).toBe(0); + }); +});