Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/web/src/api/util/api.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,32 @@ describe("handleErrorResponse", () => {
).rejects.toThrow("Google revocation handler is not configured");
});

it("keeps skipSessionRecovery local to unauthorized responses", async () => {
it("does not sign the user out on a 404 from a data endpoint", async () => {
window.history.pushState({}, "", "/day");
const signOutSpy = spyOn(session, "signOut").mockResolvedValue(undefined);
const error = createApiError(
{ status: Status.NOT_FOUND },
{ skipSessionRecovery: true, url: "/event" },
{ url: "/event" },
);

// A missing resource (e.g. syncing an event onto a not-yet-provisioned
// calendar) rethrows for the caller to handle - it must not eject the user.
await expect(
handleErrorResponse(error, { onGoogleRevoked: undefined }),
).rejects.toBe(error);

expect(signOutSpy).not.toHaveBeenCalled();
signOutSpy.mockRestore();
});

it("still signs the user out on an unauthorized data-endpoint response", async () => {
// Already on the calendar route, so signOut skips the (jsdom-unsupported)
// navigation and we can assert purely on the sign-out call.
window.history.pushState({}, "", "/week");
const signOutSpy = spyOn(session, "signOut").mockResolvedValue(undefined);
const error = createApiError(
{ status: Status.UNAUTHORIZED },
{ url: "/event" },
);

await expect(
Expand Down
8 changes: 5 additions & 3 deletions packages/web/src/api/util/api.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,13 @@ export const handleErrorResponse = async <T>(

const isAuthEndpoint = requestUrl?.includes("/signinup");

// A 404 on a data endpoint means a resource is missing (e.g. syncing a
// just-created event onto a calendar the server hasn't provisioned yet),
// not that the session is invalid - so it must never force a sign-out.
// Only genuine session-level failures (GONE/UNAUTHORIZED) do.
if (
!isAuthEndpoint &&
(status === Status.GONE ||
status === Status.NOT_FOUND ||
status === Status.UNAUTHORIZED)
(status === Status.GONE || status === Status.UNAUTHORIZED)
) {
await signOut(status);
} else if (!isAuthEndpoint) {
Expand Down
13 changes: 13 additions & 0 deletions packages/web/src/common/utils/sync/local-event-sync.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,17 @@ describe("syncLocalEventsToCloud", () => {
expect(createEvent).not.toHaveBeenCalled();
expect(clearAllEvents).not.toHaveBeenCalled();
});

it("keeps records on-device when the server has no local calendar yet, rather than posting a sentinel id", async () => {
// No local calendar in the list (e.g. it hasn't been provisioned yet).
listCalendars.mockResolvedValue([]);
getAllEvents.mockResolvedValue([createMockLocalEventRecord({}, false)]);

await expect(syncLocalEventsToCloud()).resolves.toBe(0);

// Never POST against a calendar the backend can't resolve (would 404), and
// never clear the store - the records stay put for a later sync.
expect(createEvent).not.toHaveBeenCalled();
expect(clearAllEvents).not.toHaveBeenCalled();
});
});
15 changes: 11 additions & 4 deletions packages/web/src/common/utils/sync/local-event-sync.util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type CreateEventInput } from "@core/types/event-command.contracts";
import { CalendarApi } from "@web/calendars/calendar.api";
import { getLocalCalendar } from "@web/calendars/calendar.util";
import { getLocalCalendarSentinelId } from "@web/calendars/local-calendar.sentinel";
import { type OfflineDataStore } from "@web/common/storage/offline-data/offline-data.store";
import {
ensureOfflineDataStoreReady,
Expand Down Expand Up @@ -63,11 +62,19 @@ export function createSyncLocalEventsToCloud({
if (recordsToSync.length > 0) {
const calendars = await listCalendars();
const serverLocalCalendar = getLocalCalendar(calendars);
const serverLocalCalendarId =
serverLocalCalendar?.id ?? getLocalCalendarSentinelId();

// Never fall back to the client-generated sentinel calendar id: the
// backend can't resolve it and rejects the POST with CALENDAR_NOT_FOUND
// (a 404 that used to sign the just-signed-up user out). When the
// server-side local calendar isn't available yet, keep the records
// on-device - untouched, so a later sync can push them onto the real
// calendar - rather than losing them to a rejected request.
if (!serverLocalCalendar) {
return 0;
}

for (const record of recordsToSync) {
await createEvent(toCreateInput(record, serverLocalCalendarId));
await createEvent(toCreateInput(record, serverLocalCalendar.id));
}
}

Expand Down
60 changes: 46 additions & 14 deletions packages/web/src/events/repositories/local.event.repository.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type CalendarId,
DateTimeSchema,
type EventId,
TimeZoneSchema,
Expand Down Expand Up @@ -38,21 +39,52 @@ describe("LocalEventRepository", () => {
expect(putEvent.mock.calls[0][0].isDemo).toBe(true);
});

it("throws when replacing an event that does not exist locally", async () => {
it("upserts (instead of throwing) when the edit target is absent from the store", async () => {
// The optimistic layer can resolve an edit target from the query cache
// that was never persisted to IndexedDB (e.g. a materialized recurring
// occurrence). Replacing it must not throw "Event not found".
getAllEvents.mockResolvedValue([]);

await expect(
repository.replace("c".repeat(24) as EventId, {
content: { kind: "details", title: "x", description: "" },
schedule: {
kind: "timed",
start: DateTimeSchema.parse("2026-05-05T09:00:00.000-05:00"),
end: DateTimeSchema.parse("2026-05-05T10:00:00.000-05:00"),
timeZone: TimeZoneSchema.parse("America/Chicago"),
},
recurrence: { kind: "single" },
scope: "this",
}),
).rejects.toThrow();
const id = "c".repeat(24) as EventId;
const result = await repository.replace(id, {
content: { kind: "details", title: "x", description: "" },
schedule: {
kind: "timed",
start: DateTimeSchema.parse("2026-05-05T09:00:00.000-05:00"),
end: DateTimeSchema.parse("2026-05-05T10:00:00.000-05:00"),
timeZone: TimeZoneSchema.parse("America/Chicago"),
},
recurrence: { kind: "single" },
scope: "this",
});

expect(result.id).toBe(id);
expect(putEvent).toHaveBeenCalledTimes(1);
expect(putEvent.mock.calls[0][0]).toMatchObject({
id,
isDemo: false,
event: { id, content: { title: "x" } },
});
});

it("preserves the input calendar when replacing a missing event that carries one", async () => {
getAllEvents.mockResolvedValue([]);

const id = "d".repeat(24) as EventId;
const calendarId = "e".repeat(24) as EventId;
const result = await repository.replace(id, {
content: { kind: "details", title: "y", description: "" },
calendarId: calendarId as unknown as CalendarId,
schedule: {
kind: "timed",
start: DateTimeSchema.parse("2026-05-05T09:00:00.000-05:00"),
end: DateTimeSchema.parse("2026-05-05T10:00:00.000-05:00"),
timeZone: TimeZoneSchema.parse("America/Chicago"),
},
recurrence: { kind: "single" },
scope: "this",
});

expect(result.calendarId).toBe(calendarId as unknown as CalendarId);
});
});
40 changes: 27 additions & 13 deletions packages/web/src/events/repositories/local.event.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type RecurrenceScope,
type ReplaceEventInput,
} from "@core/types/event-command.contracts";
import { getLocalCalendarSentinelId } from "@web/calendars/local-calendar.sentinel";
import {
getOfflineDataStore,
type OfflineDataStore,
Expand Down Expand Up @@ -41,17 +42,19 @@ export class LocalEventRepository implements EventRepository {
return records.map((record) => record.event);
}

private async getRecordById(id: EventId): Promise<LocalEventRecord> {
private async findRecordById(
id: EventId,
): Promise<LocalEventRecord | undefined> {
const records = await this.store.getAllEvents();
const record = records.find((r) => r.id === id);
if (!record) {
throw new Error(`Event not found: ${id}`);
}
return record;
return records.find((r) => r.id === id);
}

async getById(id: EventId): Promise<Event> {
return (await this.getRecordById(id)).event;
const record = await this.findRecordById(id);
if (!record) {
throw new Error(`Event not found: ${id}`);
}
return record.event;
}

async create(input: CreateEventInput): Promise<Event> {
Expand All @@ -77,30 +80,41 @@ export class LocalEventRepository implements EventRepository {
}

async replace(id: EventId, input: ReplaceEventInput): Promise<Event> {
const existingRecord = await this.getRecordById(id);
const existing = existingRecord.event;
// The optimistic layer resolves the edit target from the react-query
// cache, which can hold an event that never made it into IndexedDB - most
// commonly a materialized recurring-occurrence instance (its id differs
// from the stored series record). Rather than throwing "Event not found"
// on that mismatch, upsert: persist the edit so an offline change isn't
// lost, falling back to the local calendar when the input carries no
// calendarId of its own.
const existingRecord = await this.findRecordById(id);
const existing = existingRecord?.event;

const recurrence =
input.recurrence.kind === "preserve"
? existing.recurrence
? (existing?.recurrence ?? { kind: "single" as const })
: input.recurrence.kind === "series"
? { kind: "series" as const, rules: input.recurrence.rules }
: { kind: "single" as const };

const event: Event = {
...existing,
calendarId: input.calendarId ?? existing.calendarId,
id,
calendarId:
input.calendarId ??
existing?.calendarId ??
getLocalCalendarSentinelId(),
content: input.content,
schedule: input.schedule,
recurrence,
createdAt: existing?.createdAt ?? nowDateTime(),
updatedAt: nowDateTime(),
};

const record: LocalEventRecord = {
version: 2,
id,
event,
isDemo: existingRecord.isDemo,
isDemo: existingRecord?.isDemo ?? false,
};
await this.store.putEvent(record);
return event;
Expand Down