From 6d3b169f23ea3574720b677f2328832866ef35aa Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Sat, 31 Oct 2026 14:40:01 -0700 Subject: [PATCH] case 4652 - a record that no longer exists gives "not found" instead of a server error; reports leave it out Co-Authored-By: Claude Opus 5.5 --- devdocs/modernization/NET10-PORT.md | 2 +- docs/8.0/ayanova/docs/changelog.md | 1 + server/AyaNova/Controllers/ReminderController.cs | 4 ++++ server/AyaNova/biz/ContractBiz.cs | 2 +- server/AyaNova/biz/PMBiz.cs | 13 ++++++++----- server/AyaNova/biz/PartAssemblyBiz.cs | 2 +- server/AyaNova/biz/QuoteBiz.cs | 13 ++++++++----- server/AyaNova/biz/ReminderBiz.cs | 2 +- server/AyaNova/biz/ReviewBiz.cs | 3 ++- server/AyaNova/biz/WorkOrderBiz.cs | 13 ++++++++----- 10 files changed, 35 insertions(+), 20 deletions(-) diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index d2a158ef..d489e0c6 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -275,7 +275,7 @@ Found during the port, out of scope. One line each. - **Done in case 4699.** Linux desktop manual (`ops-install-linux-desktop.md`), pre-existing, same in 8.2.4: `sudo mkdir ~/.ayanova-data` creates the folder in the user's home, but `sudo ./AyaNova` runs as root, so `AYANOVA_DATA_PATH` `~/.ayanova-data` resolves to `/root/.ayanova-data` and the user's folder is never used (data, logs and backups end up under `/root`). Found in the 8.3.0 desktop zip test. Fix the manual (absolute data path, or not running as root) after the release - **Own case after the port (John):** the subscription server setup script is hard-coded in `biz-subscription-server.vue`, so every change needs a new Sockeye client build. Make it editable without a rebuild - **Done in case 4703.** **Improvement, own case after the port (John to create):** bring back logging of JavaScript error details from report page console errors, as Sockeye's port did. AyaNova logs only `ERROR: {message text}`; the per-argument detail (`arg instanceof Error ? arg.message : arg`) is commented out at `biz/ReportBiz.cs:873-876` since case 4586, because `IJSHandle.ExecutionContext` was removed. Sockeye 8.1.0 (`server/biz/ReportBiz.cs:575`) evaluates on the handle instead: `args.Message.Args.Select(arg => arg.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg"))`, which also needs the console handler to be `async` again -- Pre-existing, same in 8.2.4 (found in the 4579 sandbox test, John: Deferred): rendering a report for a record that does not exist (e.g. work order ids 1 and 2 in a trial without sample data) fails the job with a generic `ArgumentNullException` ("Value cannot be null. (Parameter 'o')") from `JObject.FromObject` in `WorkOrderBiz.GetReportData` (`WorkOrderBiz.cs:1030`), instead of skipping the missing record or saying it is not found. Other object types' `GetReportData` likely do the same +- **Fixed in case 4652** (work orders, quotes and PMs now leave the missing record out; the other object types already did). Pre-existing, same in 8.2.4 (found in the 4579 sandbox test, John: Deferred): rendering a report for a record that does not exist (e.g. work order ids 1 and 2 in a trial without sample data) fails the job with a generic `ArgumentNullException` ("Value cannot be null. (Parameter 'o')") from `JObject.FromObject` in `WorkOrderBiz.GetReportData` (`WorkOrderBiz.cs:1030`), instead of skipping the missing record or saying it is not found. Other object types' `GetReportData` likely do the same - **Roles audit, case 4735 (John, 2026-10-25, this release; found in 4719):** check every object's rights as documented in the manual (each page's "Authorization Roles required" section and `ay-biz-admin-roles.md`) against `biz/BizRoles.cs`. Report templates were changeable by Business administration - restricted although the roles page uses exactly that case as its example of a view-only restricted role (fixed in 4719). If that slipped through there could be others; for each mismatch decide whether the code or the page is wrong - Found 2026-10-27 (John: later case, no BC customers): when the browser cannot name its time zone, the client uses `America/New_York` (`raven-client` `api/locale.js` `getResolvedTimeZoneName`; Sockeye's client the same), so every date and time is shown, entered and sent to reports in New York time. Windows' new "British Columbia Standard Time" zone (BC permanent UTC−7 from November 2026) does this: Chrome returns `undefined` for it, although it knows `America/Vancouver`'s new rules. Seen on John's PC as backup times three hours late on devtest and Sockeye (the servers were right). Workaround: the time zone override in user settings. Fix: fall back to the browser's current UTC offset (fixed-offset zone) instead of New York - Found in the 8.3.0 Cypress runs (2026-10-26; pre-existing, 8.2.4 too): on a first visit over a slow connection the login page can move the keyboard focus back to the username field while it settles (the field has `v-focus` and is re-inserted when the page redraws), so part of a password typed early lands in the username field in plain view; and the service worker reloads the page once when it takes control (`App.vue`, `controllerchange`), clearing both fields. Both within the first seconds of a first visit diff --git a/docs/8.0/ayanova/docs/changelog.md b/docs/8.0/ayanova/docs/changelog.md index 729007b8..88dbb398 100644 --- a/docs/8.0/ayanova/docs/changelog.md +++ b/docs/8.0/ayanova/docs/changelog.md @@ -17,6 +17,7 @@ IN PROGRESS **Changed** - Server: in some cases the automatic backup could be skipped for one day after the AyaNova server was restarted or a manual backup was made; it now always runs at the next chosen [backup time](ops-form-backup.md#backup-time). +- Server: opening a record that has since been deleted (for example from an old link) now shows a "not found" message instead of a server error, and a report printed for records of which some have since been deleted now prints the remaining ones instead of failing. ### AyaNova 8.3.0 diff --git a/server/AyaNova/Controllers/ReminderController.cs b/server/AyaNova/Controllers/ReminderController.cs index 25822cf7..1f288d51 100644 --- a/server/AyaNova/Controllers/ReminderController.cs +++ b/server/AyaNova/Controllers/ReminderController.cs @@ -76,6 +76,8 @@ namespace AyaNova.Api.Controllers if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); var o = await biz.GetAsync(id); + if (o == null && !biz.HasErrors) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); if (o == null) return BadRequest(new ApiErrorResponse(biz.Errors)); return Ok(ApiOkResponse.Response(o)); @@ -145,6 +147,8 @@ namespace AyaNova.Api.Controllers if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); var o = await biz.GetAsync(id); + if (o == null && !biz.HasErrors) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); if (o == null) return BadRequest(new ApiErrorResponse(biz.Errors)); return Ok(ApiOkResponse.Response(new diff --git a/server/AyaNova/biz/ContractBiz.cs b/server/AyaNova/biz/ContractBiz.cs index c554d869..d15ac73e 100644 --- a/server/AyaNova/biz/ContractBiz.cs +++ b/server/AyaNova/biz/ContractBiz.cs @@ -133,7 +133,7 @@ MULTIPLE discount / markup ITEMS .AsNoTracking() .SingleOrDefaultAsync(m => m.Id == id); - if (populateDisplayFields) + if (populateDisplayFields && ret != null) await PopulateVizFields(ret); if (logTheGetEvent && ret != null) diff --git a/server/AyaNova/biz/PMBiz.cs b/server/AyaNova/biz/PMBiz.cs index 8bb209a3..860b8e86 100644 --- a/server/AyaNova/biz/PMBiz.cs +++ b/server/AyaNova/biz/PMBiz.cs @@ -833,7 +833,10 @@ namespace AyaNova.Biz foreach (long batchId in batch) { if (!ReportRenderManager.KeepGoing(jobId)) return null; - batchResults.Add(await PMGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); + var o = await PMGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true); + //case 4652: a record that no longer exists is left out, as the other objects' report data does + if (o != null) + batchResults.Add(o); } foreach (PM w in batchResults) @@ -1621,7 +1624,7 @@ namespace AyaNova.Biz if (UserIsSubContractorFull || UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.PMItemExpense.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsTechRestricted && ret.UserId != UserId)//tech restricted can only see their own expenses + if (UserIsTechRestricted && ret != null && ret.UserId != UserId)//tech restricted can only see their own expenses { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; @@ -1980,7 +1983,7 @@ namespace AyaNova.Biz { var ret = await ct.PMItemLabor.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId) + if (UserIsRestrictedType && ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; @@ -3568,7 +3571,7 @@ namespace AyaNova.Biz internal async Task ScheduledUserGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.PMItemScheduledUser.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId)//restricted users can only see their own + if (UserIsRestrictedType && ret != null && ret.UserId != UserId)//restricted users can only see their own return null; if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); @@ -4084,7 +4087,7 @@ namespace AyaNova.Biz internal async Task TravelGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.PMItemTravel.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId) + if (UserIsRestrictedType && ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; diff --git a/server/AyaNova/biz/PartAssemblyBiz.cs b/server/AyaNova/biz/PartAssemblyBiz.cs index 35f5849e..8445bda0 100644 --- a/server/AyaNova/biz/PartAssemblyBiz.cs +++ b/server/AyaNova/biz/PartAssemblyBiz.cs @@ -71,7 +71,7 @@ namespace AyaNova.Biz if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); - if (populatePartNames && ret.Items != null && ret.Items.Count > 0) + if (populatePartNames && ret != null && ret.Items != null && ret.Items.Count > 0) { var partIdList = ret.Items.Select(z => z.PartId).ToArray(); var PickList = PickListFactory.GetAyaPickList(AyaType.Part); diff --git a/server/AyaNova/biz/QuoteBiz.cs b/server/AyaNova/biz/QuoteBiz.cs index 555b19f1..9be92ef9 100644 --- a/server/AyaNova/biz/QuoteBiz.cs +++ b/server/AyaNova/biz/QuoteBiz.cs @@ -810,7 +810,10 @@ namespace AyaNova.Biz foreach (long batchId in batch) { if (!ReportRenderManager.KeepGoing(jobId)) return null; - batchResults.Add(await QuoteGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); + var o = await QuoteGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true); + //case 4652: a record that no longer exists is left out, as the other objects' report data does + if (o != null) + batchResults.Add(o); } foreach (Quote w in batchResults) @@ -1854,7 +1857,7 @@ namespace AyaNova.Biz if (UserIsSubContractorFull || UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.QuoteItemExpense.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsTechRestricted && ret.UserId != UserId)//tech restricted can only see their own expenses + if (UserIsTechRestricted && ret != null && ret.UserId != UserId)//tech restricted can only see their own expenses { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; @@ -2229,7 +2232,7 @@ namespace AyaNova.Biz { var ret = await ct.QuoteItemLabor.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId) + if (UserIsRestrictedType && ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; @@ -3862,7 +3865,7 @@ namespace AyaNova.Biz internal async Task ScheduledUserGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.QuoteItemScheduledUser.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId)//restricted users can only see their own + if (UserIsRestrictedType && ret != null && ret.UserId != UserId)//restricted users can only see their own return null; if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); @@ -4405,7 +4408,7 @@ namespace AyaNova.Biz internal async Task TravelGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.QuoteItemTravel.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId) + if (UserIsRestrictedType && ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; diff --git a/server/AyaNova/biz/ReminderBiz.cs b/server/AyaNova/biz/ReminderBiz.cs index be61c7c1..fe5783ab 100644 --- a/server/AyaNova/biz/ReminderBiz.cs +++ b/server/AyaNova/biz/ReminderBiz.cs @@ -67,7 +67,7 @@ namespace AyaNova.Biz internal async Task GetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.Reminder.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); - if (ret.UserId != UserId) + if (ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror", "A User may only retrieve their own reminders"); return null; diff --git a/server/AyaNova/biz/ReviewBiz.cs b/server/AyaNova/biz/ReviewBiz.cs index 204916f4..d7b8149d 100644 --- a/server/AyaNova/biz/ReviewBiz.cs +++ b/server/AyaNova/biz/ReviewBiz.cs @@ -71,7 +71,8 @@ namespace AyaNova.Biz var ret = await ct.Review.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); - await PopulateVizFields(ret); + if (ret != null) + await PopulateVizFields(ret); return ret; } diff --git a/server/AyaNova/biz/WorkOrderBiz.cs b/server/AyaNova/biz/WorkOrderBiz.cs index 99c54b21..fcc30e4a 100644 --- a/server/AyaNova/biz/WorkOrderBiz.cs +++ b/server/AyaNova/biz/WorkOrderBiz.cs @@ -1041,7 +1041,10 @@ namespace AyaNova.Biz foreach (long batchId in batch) { if (!ReportRenderManager.KeepGoing(jobId)) return null; - batchResults.Add(await WorkOrderGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); + var o = await WorkOrderGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true); + //case 4652: a record that no longer exists is left out, as the other objects' report data does + if (o != null) + batchResults.Add(o); } foreach (WorkOrder w in batchResults) @@ -2708,7 +2711,7 @@ namespace AyaNova.Biz if (UserIsSubContractorFull || UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.WorkOrderItemExpense.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsTechRestricted && ret.UserId != UserId)//tech restricted can only see their own expenses + if (UserIsTechRestricted && ret != null && ret.UserId != UserId)//tech restricted can only see their own expenses { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; @@ -3072,7 +3075,7 @@ namespace AyaNova.Biz { var ret = await ct.WorkOrderItemLabor.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId) + if (UserIsRestrictedType && ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; @@ -5495,7 +5498,7 @@ namespace AyaNova.Biz internal async Task ScheduledUserGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.WorkOrderItemScheduledUser.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId)//restricted users can only see their own + if (UserIsRestrictedType && ret != null && ret.UserId != UserId)//restricted users can only see their own return null; if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); @@ -6254,7 +6257,7 @@ namespace AyaNova.Biz internal async Task TravelGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.WorkOrderItemTravel.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); - if (UserIsRestrictedType && ret.UserId != UserId) + if (UserIsRestrictedType && ret != null && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null;