feat: tell favoriters when an item is withdrawn (#34)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m35s
Tests / backend-unit (pull_request) Successful in 35s
Tests / frontend-e2e (pull_request) Failing after 8m35s

Deleting an item cascades its favorites away, so anyone watching it lost the record silently and heard nothing. Recipients are now collected before the delete, since after it 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.

Items that had already sold are excluded. Their favoriters were told at the point of sale, and a second "no longer available" for the same item reads as a duplicate rather than news.

The wording differs from the sale notification — withdrawn rather than sold — because the customer did not lose out to another buyer and saying so would be untrue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:29:00 -05:00
co-authored by Claude Opus 5
parent f626f27e75
commit 009c73e359
3 changed files with 142 additions and 31 deletions
@@ -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);
});
});