Report write failures instead of swallowing them

From the same pre-deploy review. None of the four write calls had an error
callback, so a failure closed the row and saved nothing while looking exactly
like success. That was survivable when the session was a localStorage value
this app controlled; after the cutover a 401 is routine - the session expires,
or is ended from another app or another tab - so silence is not.

A 401 now says so and goes to the login carrying this page as the return
target; everything else says what happened and leaves the edits on screen.

getEvents had the same gap, and it is the one that matters during the deploy
itself: between the API going out and this bundle following it, the old code
renders as signed in and shows an empty table, which reads as "the calendar
lost its data" rather than "a deploy is in progress".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:41:49 +02:00
parent 5d7221b87b
commit e8d2dc2901
2 changed files with 84 additions and 33 deletions
+59 -21
View File
@@ -3,7 +3,9 @@ import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {Event} from '../../models/event'; import {Event} from '../../models/event';
import {HttpErrorResponse} from '@angular/common/http';
import {ApiService} from '../../services/api.service'; import {ApiService} from '../../services/api.service';
import {AdminAuthService} from '../../services/admin-auth.service';
import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component"; import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component";
@Component({ @Component({
@@ -71,20 +73,21 @@ export class EventComponent implements OnInit, OnDestroy {
} }
if(this.event.eventId === undefined) { if(this.event.eventId === undefined) {
this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: (res: any) => {
if(res.eventId) {
if(res.eventId) { this.event!.eventId = res.eventId;
this.event!.eventId = res.eventId; } else {
} else { this.showCreateError = true;
this.showCreateError = true; }
return; },
} error: this.handleWriteError('The new event')
}); });
} else { } else {
// Update existing event // Update existing event
this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: () => {},
error: this.handleWriteError('Your change')
}); });
} }
} }
@@ -180,15 +183,48 @@ export class EventComponent implements OnInit, OnDestroy {
let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`); let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`);
if(deleteConfirmed && this.event) { if(deleteConfirmed && this.event) {
this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: (res: any) => {
if(res.message) { if(res.message) {
this.deleteEvent.next(this.event!.eventId); this.deleteEvent.next(this.event!.eventId);
} }
},
error: this.handleWriteError('The deletion')
}); });
} }
} }
/**
* What to do when a write fails.
*
* Before the auth cutover none of the writes here had an error callback, so
* a failure was invisible: the row closed, nothing was saved, and the user
* had every reason to think it had been. A 401 is now a routine event - the
* session expires, or is ended from another app or another tab - so silence
* is no longer survivable.
*
* A 401 means the session is gone, and nothing on this page can be saved
* until it comes back, so it goes straight to the login carrying this page
* as the return target. Everything else says what happened and leaves the
* user where they are, with their edits still on screen.
*/
private handleWriteError(action: string): (error: HttpErrorResponse) => void {
return (error: HttpErrorResponse) => {
if (error.status === 401) {
window.alert(`Your session has expired, so ${action} was not saved. Signing you in again.`);
AdminAuthService.goToLogin();
return;
}
if (error.status === 403) {
window.alert(`${action} failed: this account no longer has access to the calendar.`);
return;
}
window.alert(`${action} failed. Please try again. (${error.status || 'no response from the server'})`);
};
}
triggerMove() { triggerMove() {
if(this.editActive) { if(this.editActive) {
window.alert('Please save your changes before moving the event to a different calendar.'); window.alert('Please save your changes before moving the event to a different calendar.');
@@ -204,11 +240,13 @@ export class EventComponent implements OnInit, OnDestroy {
movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => { movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => {
// If popup is dismissed, undefined will be returned // If popup is dismissed, undefined will be returned
if(result) { if(result) {
this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: () => {
// Uses the same interface as delete as from the calendar table perspective it is the same action // Uses the same interface as delete as from the calendar table perspective it is the same action
// as a delete // as a delete
this.deleteEvent.next(result.eventId); this.deleteEvent.next(result.eventId);
},
error: this.handleWriteError('The move')
}); });
} }
}); });
+25 -12
View File
@@ -78,20 +78,33 @@ export class AdminComponent implements OnInit, OnDestroy {
return; return;
} }
this.api.getEvents(this.selectedCalendar).pipe(takeUntil(this.destroy$)).subscribe((events: Event[]): void => { this.api.getEvents(this.selectedCalendar).pipe(takeUntil(this.destroy$)).subscribe({
for (let event of events) { next: (events: Event[]): void => {
if(event.status !== 'DELETED') { for (let event of events) {
this.events.push({ if (event.status !== 'DELETED') {
...event, this.events.push({
startDateTime: new Date(event.startDateTime), ...event,
endDateTime: new Date(event.endDateTime), startDateTime: new Date(event.startDateTime),
createdDate: new Date(event.createdDate), endDateTime: new Date(event.endDateTime),
lastModifiedDate: new Date(event.lastModifiedDate) createdDate: new Date(event.createdDate),
}); lastModifiedDate: new Date(event.lastModifiedDate)
});
}
} }
this.filterEvents();
this.sortEvents();
},
// Without this a failed load is indistinguishable from an empty
// calendar - which is exactly what the old bundle looks like against
// the post-cutover API, and how a deploy in progress gets mistaken for
// lost data.
error: (error: HttpErrorResponse): void => {
if (error.status === 401) {
AdminAuthService.goToLogin();
return;
}
this.failure = error.status === 403 ? 'denied' : 'unavailable';
} }
this.filterEvents();
this.sortEvents();
}); });
} }