mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user