diff --git a/server/src/model/teams/teamNotify.model.js b/server/src/model/teams/teamNotify.model.js index 168dd66..a085642 100644 --- a/server/src/model/teams/teamNotify.model.js +++ b/server/src/model/teams/teamNotify.model.js @@ -82,6 +82,24 @@ async function replacePrefs(userId, entries) { await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode }) written.push(teamId) } + + // A Team the caller COULD have named and did not is returned to its defaults. + // + // Without this, "replace the whole set" was a lie the endpoint told: omitting an + // entry left the old preference standing, which made `teams: []` — the body the + // route requires precisely so that clearing everything is expressible — clear + // nothing at all. + // + // Reset rather than deleted, and the difference is `last_digest_at`. That column + // is the digest worker's state, not a preference; dropping the row with it would + // make every visit to the settings screen re-open a day-wide digest window and + // mail somebody a summary they already read. + for (const teamId of allowed.keys()) { + if (written.includes(teamId)) continue + // eslint-disable-next-line no-await-in-loop + await db.setPref(userId, teamId, { muted: false, emailMode: 'off' }) + } + return { written, prefs: await listPrefs(userId) } } diff --git a/server/test/teamNotify.test.js b/server/test/teamNotify.test.js index 303d6aa..ccdea1c 100644 --- a/server/test/teamNotify.test.js +++ b/server/test/teamNotify.test.js @@ -214,6 +214,36 @@ test('replacePrefs ignores a Team the caller is not in', async () => { assert.equal(store.prefs.some((p) => p.user_id === 20 && p.team_id === 2), false) }) +test('replacePrefs really replaces: an omitted Team returns to its defaults', async () => { + await notifyModel.replacePrefs(10, [ + { teamId: 1, muted: true, emailMode: 'immediate' }, + { teamId: 2, muted: true, emailMode: 'digest' }, + ]) + // Now save a set that names only Team 1. Team 2 was not mentioned, so it goes + // back to defaults — otherwise "PUT the whole set" is a lie and `teams: []` + // clears nothing, which is the body the route requires so that clearing + // everything is expressible in the first place. + await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'immediate' }]) + assert.deepEqual(await notifyModel.prefFor(10, 1), { teamId: 1, muted: true, emailMode: 'immediate' }) + assert.deepEqual(await notifyModel.prefFor(10, 2), { teamId: 2, muted: false, emailMode: 'off' }) +}) + +test('an empty set clears every preference the caller holds', async () => { + await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'digest' }]) + await notifyModel.replacePrefs(10, []) + assert.equal((await notifyModel.prefFor(10, 1)).muted, false) + assert.equal((await notifyModel.recipientIds(1)).includes(10), true) +}) + +test('the reset does not touch last_digest_at — that is the worker’s column', async () => { + await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'digest' }]) + const row = store.prefs.find((p) => p.user_id === 10 && p.team_id === 1) + row.last_digest_at = '2026-08-18T00:00:00Z' + await notifyModel.replacePrefs(10, []) + assert.equal(row.last_digest_at, '2026-08-18T00:00:00Z', + 'dropping it would re-open a day-wide window on every visit to the settings screen') +}) + test('an unknown email mode falls back to off rather than reaching the column', async () => { await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'hourly' }]) assert.equal((await notifyModel.prefFor(10, 1)).emailMode, 'off')