fix(coach): bind the delete journal to the account and record it was sent for

A tab outlives a logout, another login and a switch into a managed
profile. A delete re-sent in another scope met a 404 that read as
"already gone" and dropped the entry, or tripped the record fence and
toasted a failure for a row the person could not see. Entries now carry
their owner, only the current owner's are re-sent, and a session end
clears the journal with the rest of the session state.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 10:29:36 +02:00
1 parent c40b21de3d
commit 587ea35d34
6 files changed
+282 -48

No files matched your search

@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearCoachDeleteJournal,
coachDeleteJournalOwner,
readCoachDeleteJournal,
resendCoachDeleteJournal,
writeCoachDeleteJournal,
} from "../delete-journal";
function memoryStorage(): Storage {
const map = new Map<string, string>();
return {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (key) => map.get(key) ?? null,
key: (index) => [...map.keys()][index] ?? null,
removeItem: (key) => void map.delete(key),
setItem: (key, value) => void map.set(key, String(value)),
};
}
const KEY = "healthlog:coach-conversation-deletes";
let storage: Storage;
beforeEach(() => {
storage = memoryStorage();
vi.stubGlobal("window", { sessionStorage: storage });
});
afterEach(() => {
vi.unstubAllGlobals();
});
const alice = coachDeleteJournalOwner("user-a", null);
const aliceInManaged = coachDeleteJournalOwner("user-a", "managed-1");
const bob = coachDeleteJournalOwner("user-b", null);
describe("Coach delete journal", () => {
it("binds each entry to the account and the record it was sent for", () => {
writeCoachDeleteJournal(alice, "c1", true);
writeCoachDeleteJournal(aliceInManaged, "c2", true);
expect(readCoachDeleteJournal(alice)).toEqual(["c1"]);
expect(readCoachDeleteJournal(aliceInManaged)).toEqual(["c2"]);
expect(readCoachDeleteJournal(bob)).toEqual([]);
writeCoachDeleteJournal(alice, "c1", false);
expect(readCoachDeleteJournal(alice)).toEqual([]);
expect(readCoachDeleteJournal(aliceInManaged)).toEqual(["c2"]);
});
it("re-sends nothing after a logout and another login in the same tab", () => {
writeCoachDeleteJournal(alice, "c1", true);
const commit = vi.fn();
resendCoachDeleteJournal(bob, commit);
expect(commit).not.toHaveBeenCalled();
// The entry waits for its own scope instead of being spent on the wrong
// one, where a 404 would have read as "already gone".
expect(readCoachDeleteJournal(alice)).toEqual(["c1"]);
});
it("re-sends nothing outside the record after a switch into a managed profile", () => {
writeCoachDeleteJournal(alice, "own-1", true);
writeCoachDeleteJournal(aliceInManaged, "managed-c", true);
const inManaged = vi.fn();
resendCoachDeleteJournal(aliceInManaged, inManaged);
expect(inManaged.mock.calls).toEqual([["managed-c"]]);
const backHome = vi.fn();
resendCoachDeleteJournal(alice, backHome);
expect(backHome.mock.calls).toEqual([["own-1"]]);
});
it("drops entries without an owner instead of re-sending them anywhere", () => {
storage.setItem(KEY, JSON.stringify(["legacy-id"]));
const commit = vi.fn();
resendCoachDeleteJournal(alice, commit);
expect(commit).not.toHaveBeenCalled();
});
it("is emptied by a session end, whoever the entries belonged to", () => {
writeCoachDeleteJournal(alice, "c1", true);
writeCoachDeleteJournal(bob, "c2", true);
clearCoachDeleteJournal();
expect(storage.getItem(KEY)).toBeNull();
});
it("stays quiet without storage", () => {
vi.stubGlobal("window", undefined);
expect(() => writeCoachDeleteJournal(alice, "c1", true)).not.toThrow();
expect(readCoachDeleteJournal(alice)).toEqual([]);
expect(() => clearCoachDeleteJournal()).not.toThrow();
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Coach conversation deletes sent but not yet confirmed, kept in
* `sessionStorage` so they outlive a reload. A reload inside the undo window
* sends the DELETE with `keepalive` from the old page, and the new page's list
* read can reach the server before it: without the journal that read brings
* the row back. The new page hides every journaled id and sends its DELETE
* again; a 404 then means it is already gone.
*
* Every entry carries the owner it was sent for: the signed-in account and
* the record it was reading (`<userId>:<recordScope | "own">`). A tab outlives
* a logout, another login and a switch into a managed profile, and a delete
* re-sent in any of those other scopes asks the wrong record: the server
* answers 404, which would read as "already gone" and drop the entry while the
* conversation still exists, or the record fence refuses it and a failure
* toast names a row the person cannot see. So only entries of the current
* owner are re-sent, which is also what keeps a 404 meaningful, and a session
* end clears the journal with the rest of the session's state.
*/
const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes";
export interface CoachDeleteJournalEntry {
id: string;
owner: string;
}
/** The journal owner for an account reading `recordScope` (null = own). */
export function coachDeleteJournalOwner(
userId: string,
recordScope: string | null,
): string {
return `${userId}:${recordScope ?? "own"}`;
}
function safeSessionStorage(): Storage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}
function readAll(storage: Storage): CoachDeleteJournalEntry[] {
try {
const raw = storage.getItem(DELETE_JOURNAL_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
// An entry without an owner (the earlier bare-id shape) cannot be bound
// to a scope and is dropped rather than re-sent anywhere.
return parsed.filter(
(entry): entry is CoachDeleteJournalEntry =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as CoachDeleteJournalEntry).id === "string" &&
typeof (entry as CoachDeleteJournalEntry).owner === "string",
);
} catch {
return [];
}
}
/** The ids journaled for `owner`, and only those. */
export function readCoachDeleteJournal(owner: string): string[] {
const storage = safeSessionStorage();
if (!storage) return [];
return readAll(storage)
.filter((entry) => entry.owner === owner)
.map((entry) => entry.id);
}
/**
* Re-send what a previous page of this tab could not confirm, for `owner`
* only: another account's or another record's entries stay where they are
* until their own scope comes back or the session ends.
*/
export function resendCoachDeleteJournal(
owner: string,
commit: (id: string) => void,
): void {
for (const id of readCoachDeleteJournal(owner)) commit(id);
}
/** Add (`present`) or remove one delete of `owner`. Best effort. */
export function writeCoachDeleteJournal(
owner: string,
id: string,
present: boolean,
): void {
const storage = safeSessionStorage();
if (!storage) return;
try {
const entries = readAll(storage).filter(
(entry) => !(entry.owner === owner && entry.id === id),
);
if (present) entries.push({ id, owner });
if (entries.length === 0) storage.removeItem(DELETE_JOURNAL_KEY);
else storage.setItem(DELETE_JOURNAL_KEY, JSON.stringify(entries));
} catch {
// Storage unavailable: the keepalive request still carries the delete.
}
}
/** Forget every journaled delete, whoever it belonged to (a session end). */
export function clearCoachDeleteJournal(): void {
const storage = safeSessionStorage();
if (!storage) return;
try {
storage.removeItem(DELETE_JOURNAL_KEY);
} catch {
return;
}
}