diff --git a/.github/workflows/changerawr-sync.yml b/.github/workflows/changerawr-sync.yml index 364b2dacf..1aa26b8ed 100644 --- a/.github/workflows/changerawr-sync.yml +++ b/.github/workflows/changerawr-sync.yml @@ -27,63 +27,76 @@ jobs: id: prepare_notes env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} run: | - set -eo pipefail - - # Function to extract release notes from PR body - extract_release_notes() { - local body="$1" - - # Remove "Summary by CodeRabbit" section and auto-generated comment line - local cleaned_body="$(printf '%s\n' "$body" \ - | grep -v '' \ - | awk ' - BEGIN { skip=0 } - /^## Summary by CodeRabbit/ { skip=1; next } - /^## / && skip==1 { skip=0 } - skip==0 { print } - ')" - - # Try to extract content under "## Release Notes" heading - local notes="$(printf '%s\n' "$cleaned_body" \ - | awk 'f && /^## /{exit} /^## Release Notes/{f=1; next} f')" - - # If no specific section found, use the entire cleaned body - if [ -z "$notes" ]; then - notes="$cleaned_body" - fi - - printf '%s\n' "$notes" - } - - echo "Fetching PR #${{ github.event.pull_request.number }} details..." - + set -euo pipefail + + echo "Fetching PR #${PR_NUMBER} details..." + # Fetch the PR body using GitHub CLI - PR_BODY=$(gh pr view "${{ github.event.pull_request.number }}" --json body --jq '.body' 2>/dev/null || echo "") - - NOTES="" - if [ -n "$PR_BODY" ]; then - echo "PR body found, extracting release notes..." - NOTES="$(extract_release_notes "$PR_BODY")" - fi - - # Fallback to PR title and recent commits if no body found - if [ -z "$NOTES" ] || [ "$NOTES" = "" ]; then - echo "No PR body found, using PR title and commits..." - NOTES="## ${{ github.event.pull_request.title }}" + PR_BODY="$(gh pr view "$PR_NUMBER" --json body --jq '.body' 2>/dev/null || echo "")" + export PR_BODY + + # Strip CodeRabbit content and pull out the Release Notes section + NOTES="$(python3 <<'PY' + import os, re + + body = os.environ.get("PR_BODY", "") + + # 1. Drop the whole CodeRabbit auto-generated block (start marker .. end marker) + body = re.sub( + r"" + r".*?" + r"", + "", + body, + flags=re.IGNORECASE | re.DOTALL, + ) + + # 2. Drop any remaining HTML comments (covers unpaired/renamed markers) + body = re.sub(r"", "", body, flags=re.DOTALL) + + # 3. Drop a leftover "Summary by CodeRabbit" section: heading -> next h1/h2 or EOF + body = re.sub( + r"^#{1,6}\s*Summary by CodeRabbit\b.*?(?=^#{1,2}\s|\Z)", + "", + body, + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL, + ) + + # 4. Prefer an explicit "Release Notes" section when the author wrote one + m = re.search( + r"^#{1,6}\s*Release Notes\s*$\n(?P.*?)(?=^#{1,2}\s|\Z)", + body, + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL, + ) + notes = m.group("notes") if m and m.group("notes").strip() else body + + # Collapse runs of blank lines and trim + notes = re.sub(r"\n{3,}", "\n\n", notes).strip() + print(notes) + PY + )" + + # Fallback to PR title and recent commits if nothing usable remains + if [ -z "${NOTES//[[:space:]]/}" ]; then + echo "No usable PR body found, using PR title and commits..." + NOTES="## ${PR_TITLE}" NOTES="$NOTES"$'\n\n'"$(git log -n 5 --pretty=format:'- %s')" fi - - # Save to file and environment - echo "$NOTES" > release_notes.txt - - # For multiline output, use delimiter + + # Save to file and step output + printf '%s\n' "$NOTES" > release_notes.txt + + # For multiline output, use a delimiter that cannot appear in the content + DELIM="RELEASE_NOTES_EOF_$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" { - echo 'RELEASE_NOTES<> "$GITHUB_OUTPUT" - + echo "Release notes prepared:" cat release_notes.txt diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 82649bcf0..377d3279d 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -142,17 +142,43 @@ jobs: }); const pr = prs.data.find(p => p.merged_at); const fs = require('fs'); - if (pr) { - const body = (pr.body || '') - .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') + + // Removes a leftover "Summary by CodeRabbit" section that survived + // marker stripping. Ends at the next heading or horizontal rule so a + // summary placed above the author's description cannot eat the body. + const stripSummarySection = (text) => { + const out = []; + let skip = false; + for (const line of text.split('\n')) { + if (/^#{1,6}\s*Summary by CodeRabbit\b/i.test(line)) { skip = true; continue; } + if (skip && (/^#{1,6}\s/.test(line) || /^-{3,}\s*$/.test(line))) skip = false; + if (!skip) out.push(line); + } + return out.join('\n'); + }; + + const cleanBody = (raw) => { + let text = raw || ''; + // 1. Drop the whole CodeRabbit auto-generated block (start marker .. end marker) + text = text.replace( + /[\s\S]*?/gi, + '' + ); + // 2. Drop any remaining HTML comments (covers unpaired/renamed markers) + text = text.replace(//g, ''); + // 3. Drop a leftover CodeRabbit summary section + text = stripSummarySection(text); + // 4. Existing wording normalisation + text = text .replace(/^(#+\s*)(Pull Request Description|PR Description)\s*$/gim, '$1Summary') .replace(/Pull Request Description|PR Description/gi, 'Summary') - .replace(/\bPR\b/g, 'Release') - .trim() || 'No release notes provided.'; - fs.writeFileSync('release_notes.md', body); - } else { - fs.writeFileSync('release_notes.md', 'No release notes provided.'); - } + .replace(/\bPR\b/g, 'Release'); + // 5. Collapse blank-line runs and trim + return text.replace(/\n{3,}/g, '\n\n').trim(); + }; + + const body = pr ? cleanBody(pr.body) : ''; + fs.writeFileSync('release_notes.md', body || 'No release notes provided.'); - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/Core/Resgrid.Model/MapLayerData.cs b/Core/Resgrid.Model/MapLayerData.cs index fa82f2fb6..9365f68be 100644 --- a/Core/Resgrid.Model/MapLayerData.cs +++ b/Core/Resgrid.Model/MapLayerData.cs @@ -16,6 +16,7 @@ namespace Resgrid.Model { //[JsonObject] + [BsonIgnoreExtraElements] public class MapLayerData //: BsonDocument { [BsonElement("type")] diff --git a/Core/Resgrid.Model/MapLayerDataFeature.cs b/Core/Resgrid.Model/MapLayerDataFeature.cs index d18c6e623..b9566aeb8 100644 --- a/Core/Resgrid.Model/MapLayerDataFeature.cs +++ b/Core/Resgrid.Model/MapLayerDataFeature.cs @@ -4,6 +4,12 @@ namespace Resgrid.Model { //[JsonObject] + // BsonNoId: the Id property below is a GeoJSON feature id, not a Mongo document id. Without this + // the driver's NamedIdMemberConvention promotes it to the class id member, which historically + // persisted it as "_id" on the embedded document. BsonIgnoreExtraElements lets those older + // documents still deserialize. + [BsonNoId] + [BsonIgnoreExtraElements] public class MapLayerDataFeature { [BsonElement("type")] diff --git a/Core/Resgrid.Model/MapLayerDataGeometry.cs b/Core/Resgrid.Model/MapLayerDataGeometry.cs index 0bd864389..435870c2c 100644 --- a/Core/Resgrid.Model/MapLayerDataGeometry.cs +++ b/Core/Resgrid.Model/MapLayerDataGeometry.cs @@ -10,6 +10,7 @@ namespace Resgrid.Model { [JsonObject] + [BsonIgnoreExtraElements] public class MapLayerDataGeometry { [BsonElement("type")] diff --git a/Core/Resgrid.Model/MapLayerDataProperties.cs b/Core/Resgrid.Model/MapLayerDataProperties.cs index 389f605bc..c024f7ba2 100644 --- a/Core/Resgrid.Model/MapLayerDataProperties.cs +++ b/Core/Resgrid.Model/MapLayerDataProperties.cs @@ -4,6 +4,7 @@ namespace Resgrid.Model { //[JsonObject] + [BsonIgnoreExtraElements] public class MapLayerDataProperties { [BsonElement("shape")] diff --git a/Core/Resgrid.Model/Services/IShiftsService.cs b/Core/Resgrid.Model/Services/IShiftsService.cs index 0b3a188b2..6dc3894fe 100644 --- a/Core/Resgrid.Model/Services/IShiftsService.cs +++ b/Core/Resgrid.Model/Services/IShiftsService.cs @@ -30,6 +30,13 @@ public interface IShiftsService /// Task<Shift>. Task SaveShiftAsync(Shift shift, CancellationToken cancellationToken = default(CancellationToken)); + /// Updates just the shift's start day, without cascading into its child collections. + /// The shift. + /// The day the shift starts on. + /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// Task<Shift>. + Task UpdateShiftStartDayAsync(Shift shift, DateTime startDay, CancellationToken cancellationToken = default(CancellationToken)); + /// Updates the shift personnel. /// The shift. diff --git a/Core/Resgrid.Services/ShiftsService.cs b/Core/Resgrid.Services/ShiftsService.cs index 2b8d0c248..5c74a4d26 100644 --- a/Core/Resgrid.Services/ShiftsService.cs +++ b/Core/Resgrid.Services/ShiftsService.cs @@ -106,6 +106,19 @@ public async Task PopulateShiftData(Shift shift, bool getDepartment, bool return await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken); } + public async Task UpdateShiftStartDayAsync(Shift shift, DateTime startDay, CancellationToken cancellationToken = default(CancellationToken)) + { + if (shift == null) + return null; + + shift.StartDay = startDay; + + // firstLevelOnly: Days, Groups, Personnel and Admins are each managed by their own + // methods. A cascading save would rewrite those child rows from whatever happens to be + // loaded on this instance, which is not what a StartDay update should touch. + return await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken, true); + } + public async Task> GetShiftGroupsForShift(int shiftId) { var groups = await _shiftGroupsRepository.GetShiftGroupsByShiftIdAsync(shiftId); @@ -143,11 +156,19 @@ public async Task> GetShiftGroupsForShift(int shiftId) public async Task UpdateShiftDatesAsync(Shift shift, List days, CancellationToken cancellationToken = default(CancellationToken)) { + if (shift == null) + return false; + + // A shift with no days yet deserializes with Days null rather than an empty collection, + // which is the normal state the first time days are added to a shift. + var existingDays = shift.Days ?? new List(); + days = days ?? new List(); + // Adding Days foreach (var day in days) { // Don't re-add days already that are apart of the shift - if (!shift.Days.Any(x => x.Day.Day == day.Day.Day && x.Day.Month == day.Day.Month && x.Day.Year == day.Day.Year)) + if (!existingDays.Any(x => x.Day.Day == day.Day.Day && x.Day.Month == day.Day.Month && x.Day.Year == day.Day.Year)) { day.ShiftId = shift.ShiftId; await _shiftDaysRepository.SaveOrUpdateAsync(day, cancellationToken); @@ -155,7 +176,7 @@ public async Task> GetShiftGroupsForShift(int shiftId) } // Removing Days - var daysToRemove = from sd in shift.Days + var daysToRemove = from sd in existingDays let day = days.FirstOrDefault(x => x.Day.Day == sd.Day.Day && x.Day.Month == sd.Day.Month && x.Day.Year == sd.Day.Year) where day == null select sd; @@ -210,6 +231,9 @@ public async Task> GetShiftGroupsForShift(int shiftId) { var trade = await GetShiftTradeByIdAsync(shiftTradeId); + if (trade?.Users == null) + return false; + var userTradeRequest = trade.Users.FirstOrDefault(x => x.UserId == userId); if (userTradeRequest != null) @@ -227,6 +251,9 @@ public async Task> GetShiftGroupsForShift(int shiftId) { var trade = await GetShiftTradeByIdAsync(shiftTradeId); + if (trade?.Users == null) + return false; + var userTradeRequest = trade.Users.FirstOrDefault(x => x.UserId == userId); if (userTradeRequest != null) @@ -709,6 +736,12 @@ public async Task> GetOpenTradeRequestsForUserAsync(strin public async Task GetShiftTradeByIdAsync(int shiftTradeId) { var trade = await _shiftSignupTradeRepository.GetByIdAsync(shiftTradeId); + + // Without this the method throws on an unknown id instead of returning null, so callers + // have no way to handle a missing trade. + if (trade == null) + return null; + trade.Users = new List(await _shiftSignupTradeUserRepository.GetShiftSignupTradeUsersByTradeIdAsync(shiftTradeId)); return trade; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/DistributionLists/SelectDListMembersByUserQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/DistributionLists/SelectDListMembersByUserQuery.cs index 82bed25a5..fb6b7b6b6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/DistributionLists/SelectDListMembersByUserQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/DistributionLists/SelectDListMembersByUserQuery.cs @@ -17,7 +17,7 @@ public string GetQuery() { var query = _sqlConfiguration.SelectDListMembersByUserQuery .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, - _sqlConfiguration.DistributionListsTable, + _sqlConfiguration.DistributionListMembersTable, _sqlConfiguration.ParameterNotation, new string[] { "%USERID%" }, new string[] { "UserId" }); diff --git a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs index 32ec422c1..b67e30f58 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -7,7 +8,6 @@ using Resgrid.Config; using Resgrid.Model; using Resgrid.Model.Services; -using Resgrid.Web.ServicesCore.Helpers; namespace Resgrid.Web.Eventing.Hubs { @@ -50,10 +50,25 @@ public ChatHub(IChatChannelService chatChannelService, IChatPermissionService ch _chatPresenceService = chatPresenceService; } + // ClaimsAuthorizationHelper reads IHttpContextAccessor.HttpContext, which is not flowed into + // hub invocations on every transport — it comes back null and NREs. HubCallerContext.User is + // the connection's authenticated principal and is the supported claim source inside a hub. + private int GetDepartmentId() + { + var claim = Context.User?.FindFirst(ClaimTypes.PrimaryGroupSid); + + return claim != null && int.TryParse(claim.Value, out var departmentId) ? departmentId : 0; + } + + private string GetUserId() + { + return Context.User?.FindFirst(ClaimTypes.PrimarySid)?.Value ?? String.Empty; + } + public override async Task OnConnectedAsync() { - var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); - var userId = ClaimsAuthorizationHelper.GetUserId(); + var departmentId = GetDepartmentId(); + var userId = GetUserId(); if (departmentId > 0 && !string.IsNullOrWhiteSpace(userId)) { @@ -132,8 +147,8 @@ private static bool RemoveUserConnection(string userId, string connectionId) public async Task Connect() { - var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); - var userId = ClaimsAuthorizationHelper.GetUserId(); + var departmentId = GetDepartmentId(); + var userId = GetUserId(); if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) return; @@ -240,8 +255,8 @@ public async Task MarkDelivered(string channelId, long seq, int? asUnitId = null /// private async Task<(ChatChannel Channel, string UserId)?> ResolveAccessibleChannelAsync(string channelId, int? asUnitId) { - var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); - var userId = ClaimsAuthorizationHelper.GetUserId(); + var departmentId = GetDepartmentId(); + var userId = GetUserId(); if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) return null; @@ -272,8 +287,8 @@ private async Task ResolveAccessibleChannelOrThrowAsync(string chan public async Task Heartbeat() { - var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); - var userId = ClaimsAuthorizationHelper.GetUserId(); + var departmentId = GetDepartmentId(); + var userId = GetUserId(); if (departmentId > 0 && !string.IsNullOrWhiteSpace(userId)) await _chatPresenceService.TouchAsync(departmentId, userId); @@ -286,8 +301,8 @@ public async Task Heartbeat() /// public async Task SetActiveChannel(string channelId, int? asUnitId = null) { - var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); - var userId = ClaimsAuthorizationHelper.GetUserId(); + var departmentId = GetDepartmentId(); + var userId = GetUserId(); if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) return; diff --git a/Web/Resgrid.Web.Eventing/Hubs/GeolocationHub.cs b/Web/Resgrid.Web.Eventing/Hubs/GeolocationHub.cs index a9b132051..e5fcdf55e 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/GeolocationHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/GeolocationHub.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -8,7 +9,6 @@ using Resgrid.Model.Services; using Resgrid.Services; using Resgrid.Web.Eventing.Hubs.Models; -using Resgrid.Web.ServicesCore.Helpers; namespace Resgrid.Web.Eventing.Hubs { @@ -39,9 +39,19 @@ public GeolocationHub(IUnitsService unitsService, IUsersService usersService, ID _departmentsService = departmentsService; } + // ClaimsAuthorizationHelper reads IHttpContextAccessor.HttpContext, which is not flowed into + // hub invocations on every transport — it comes back null and NREs. HubCallerContext.User is + // the connection's authenticated principal and is the supported claim source inside a hub. + private int GetDepartmentId() + { + var claim = Context.User?.FindFirst(ClaimTypes.PrimaryGroupSid); + + return claim != null && int.TryParse(claim.Value, out var departmentId) ? departmentId : 0; + } + public async Task GeolocationConnect() { - var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); + var departmentId = GetDepartmentId(); if (departmentId > 0) { @@ -86,7 +96,7 @@ public async Task UnitLocationConnect(int unitId) if (unit != null) { - if (unit.DepartmentId != ClaimsAuthorizationHelper.GetDepartmentId()) + if (unit.DepartmentId != GetDepartmentId()) return; await Groups.AddToGroupAsync(Context.ConnectionId, $"UnitLocation_{unitId}"); @@ -99,7 +109,7 @@ public async Task PersonLocationConnect(string userId) { var memberships = await _departmentsService.GetAllDepartmentsForUserAsync(userId); - if (memberships != null && memberships.Any(x => x.DepartmentId == ClaimsAuthorizationHelper.GetDepartmentId())) + if (memberships != null && memberships.Any(x => x.DepartmentId == GetDepartmentId())) { await Groups.AddToGroupAsync(Context.ConnectionId, $"PersonLocation_{userId}"); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index f5169a196..0d55f3000 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -221,6 +221,7 @@ public async Task> GetChannel(string channelI [HttpPost("CreateDirectMessage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> CreateDirectMessage([FromBody] CreateDirectMessageInput input, CancellationToken cancellationToken) { @@ -237,7 +238,18 @@ public async Task> CreateDirectMessage([F return BadRequest(); var result = new ChatChannelCreatedResult(); - var channel = await _chatChannelService.GetOrCreateDirectMessageChannelAsync(DepartmentId, UserId, input.TargetUserId, input.TargetUnitId, cancellationToken); + ChatChannel channel; + + try + { + channel = await _chatChannelService.GetOrCreateDirectMessageChannelAsync(DepartmentId, UserId, input.TargetUserId, input.TargetUnitId, cancellationToken); + } + catch (UnauthorizedAccessException) + { + // The target user or unit is outside the caller's department. That is a denial, not a + // server fault, and the rest of this controller answers it with a 403. + return StatusCode(StatusCodes.Status403Forbidden); + } if (channel != null) { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx index 752a023ef..2134bf54a 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx @@ -182,10 +182,13 @@ export default function MapboxMapView({ const layerIdsRef = useRef([]); const sourceIdsRef = useRef([]); const [styleReady, setStyleReady] = useState(false); + const [initError, setInitError] = useState(null); useEffect(() => { let cancelled = false; + setInitError(null); + const initializeMapAsync = async () => { if (!mapContainerRef.current) { return; @@ -212,7 +215,27 @@ export default function MapboxMapView({ map.addControl(new mapboxgl.NavigationControl(), 'top-left'); mapRef.current = map; + let styleLoaded = false; + + // mapbox-gl reports an unreachable style URL or a rejected access token on this event rather + // than throwing, so without a listener a misconfigured department just gets a blank canvas. + // Only pre-load failures are fatal: once the style is up, transient tile errors must not + // replace a working map with an error overlay. + const handleMapError = (event: { error?: { message?: string } }) => { + console.error('Mapbox map error', event?.error ?? event); + + if (cancelled || styleLoaded) { + return; + } + + const message = event?.error?.message?.trim(); + setInitError(message && message.length > 0 ? message : 'Unable to load the map.'); + }; + + map.on('error', handleMapError); + const handleStyleReady = () => { + styleLoaded = true; setStyleReady(true); map.resize(); }; @@ -226,6 +249,7 @@ export default function MapboxMapView({ if (cancelled) { window.removeEventListener('resize', resizeMap); + map.off('error', handleMapError); map.remove(); mapRef.current = null; return; @@ -233,6 +257,7 @@ export default function MapboxMapView({ return () => { window.removeEventListener('resize', resizeMap); + map.off('error', handleMapError); clearLayerArtifacts(map, layerIdsRef.current, sourceIdsRef.current); layerIdsRef.current = []; sourceIdsRef.current = []; @@ -245,9 +270,25 @@ export default function MapboxMapView({ let cleanupMap: (() => void) | undefined; - void initializeMapAsync().then((cleanup) => { - cleanupMap = cleanup; - }); + void initializeMapAsync() + .then((cleanup) => { + cleanupMap = cleanup; + }) + .catch((mapInitError: unknown) => { + // mapbox-gl is the largest chunk in the bundle, so its dynamic import is the one most + // likely to be in flight when the user navigates away or when a deploy rotates the + // hashed chunk names. Without this catch the rejection escapes as an unhandled promise + // rejection with no stack and no URL instead of a visible map error. + console.error('Failed to initialize the Mapbox map', mapInitError); + + if (!cancelled) { + setInitError( + mapInitError instanceof Error && mapInitError.message.trim().length > 0 + ? mapInitError.message + : 'Unable to load the map.', + ); + } + }); return () => { cancelled = true; @@ -366,5 +407,17 @@ export default function MapboxMapView({ } }, [layerVisibility, layers, styleReady]); - return
; + // The container has to stay mounted through a failure. Swapping it out for the error nulls + // mapContainerRef, and the retry effect bails on its !mapContainerRef.current guard before + // React has re-rendered the cleared error, leaving a blank map that never recovers. + return ( + <> +
+ {initError && ( +
+
{initError}
+
+ )} + + ); } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css index 20f5ae033..987af5efd 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css @@ -112,6 +112,14 @@ background: rgba(255, 255, 255, 0.72); } +.rg-map__overlay--message { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + text-align: center; +} + .rg-map__footer { display: flex; justify-content: flex-end; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs index f8e144022..7525a03d3 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs @@ -103,6 +103,9 @@ public async Task EditShiftDetails(int shiftId) var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + if (shift == null) + return RedirectToAction("Index"); + if (shift.DepartmentId != DepartmentId) return Unauthorized(); @@ -118,9 +121,19 @@ public async Task EditShiftDetails(int shiftId) [Authorize(Policy = ResgridResources.Shift_Update)] public async Task EditShiftDetails(EditShiftView model, IFormCollection form, CancellationToken cancellationToken) { + if (model?.Shift == null) + return RedirectToAction("Index"); + if (ModelState.IsValid) { var shift = await _shiftsService.GetShiftByIdAsync(model.Shift.ShiftId); + + if (shift == null) + return RedirectToAction("Index"); + + if (shift.DepartmentId != DepartmentId) + return Unauthorized(); + shift.Name = model.Shift.Name; shift.Code = model.Shift.Code; shift.Color = model.Shift.Color; @@ -323,6 +336,14 @@ public async Task RequestTrade(int shiftSignUpId) { var model = new RequestTradeView(); model.Signup = await _shiftsService.GetShiftSignupByIdAsync(shiftSignUpId); + + if (model.Signup == null) + return RedirectToAction("YourShifts"); + + // A trade is offered on your own signup, so only its owner gets this page. + if (model.Signup.UserId != UserId) + return Unauthorized(); + model.ShiftDay = await _shiftsService.GetShiftDayForSignupAsync(shiftSignUpId); return View(model); @@ -333,7 +354,15 @@ public async Task RequestTrade(int shiftSignUpId) public async Task ShiftCalendar(int shiftId) { var model = new ShiftCalendarView(); - model.Shift = await _shiftsService.GetShiftByIdAsync(shiftId); + var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + + if (shift == null) + return RedirectToAction("Index"); + + if (shift.DepartmentId != DepartmentId) + return Unauthorized(); + + model.Shift = shift; return View(model); } @@ -342,6 +371,9 @@ public async Task ShiftCalendar(int shiftId) [Authorize(Policy = ResgridResources.Shift_View)] public async Task RequestTrade(RequestTradeView model, IFormCollection form, CancellationToken cancellationToken) { + if (model?.Signup == null) + return RedirectToAction("YourShifts"); + string[] users = null; if (form.ContainsKey("users")) @@ -350,8 +382,16 @@ public async Task RequestTrade(RequestTradeView model, IFormColle if (users == null || !users.Any()) ModelState.AddModelError("users", "You must specify users to request a trade from. Only qualified users will populate the list."); - model.Signup = await _shiftsService.GetShiftSignupByIdAsync(model.Signup.ShiftSignupId); - model.ShiftDay = await _shiftsService.GetShiftDayForSignupAsync(model.Signup.ShiftSignupId); + var shiftSignupId = model.Signup.ShiftSignupId; + model.Signup = await _shiftsService.GetShiftSignupByIdAsync(shiftSignupId); + + if (model.Signup == null) + return RedirectToAction("YourShifts"); + + if (model.Signup.UserId != UserId) + return Unauthorized(); + + model.ShiftDay = await _shiftsService.GetShiftDayForSignupAsync(shiftSignupId); if (ModelState.IsValid && users != null) { @@ -387,8 +427,12 @@ public async Task EditShiftDays(int shiftId) { var model = new EditShiftView(); + // A missing or bogus shiftId binds to 0 and GetShiftByIdAsync returns null for it. var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + if (shift == null) + return RedirectToAction("Index"); + if (shift.DepartmentId != DepartmentId) return Unauthorized(); @@ -401,23 +445,36 @@ public async Task EditShiftDays(int shiftId) [Authorize(Policy = ResgridResources.Shift_Update)] public async Task EditShiftDays(EditShiftView model, CancellationToken cancellationToken) { + if (model?.Shift == null) + return RedirectToAction("Index"); + var shift = await _shiftsService.GetShiftByIdAsync(model.Shift.ShiftId); + if (shift == null) + return RedirectToAction("Index"); + if (shift.DepartmentId != DepartmentId) return Unauthorized(); var days = new List(); + DateTime? startDay = null; + if (!String.IsNullOrWhiteSpace(model.Dates)) { model.Shift.Days = new Collection(); var dates = model.Dates.Split(char.Parse(",")); + // A shift that has no days yet comes back from the JSON projection with Days null + // rather than an empty collection, and that is exactly the case this branch is for. + var hasExistingDays = shift.Days != null && shift.Days.Count > 0; + for (int i = 0; i < dates.Length; i++) { var date = DateTimeHelpers.ConvertKendoCalDate(dates[i]); - if (shift.Days.Count == 0 && i == 0) - model.Shift.StartDay = date; + // First posted date wins, matching how NewShift seeds StartDay on creation. + if (!hasExistingDays && i == 0) + startDay = date; var day = new ShiftDay(); day.Day = date; @@ -427,11 +484,16 @@ public async Task EditShiftDays(EditShiftView model, Cancellation } else { - model.Shift.StartDay = DateTime.UtcNow.TimeConverter(await _departmentService.GetDepartmentByIdAsync(DepartmentId, false)); + startDay = DateTime.UtcNow.TimeConverter(await _departmentService.GetDepartmentByIdAsync(DepartmentId, false)); } await _shiftsService.UpdateShiftDatesAsync(shift, days, cancellationToken); + // StartDay used to be assigned to the posted model, which is never saved, so it never + // reached the database. Write it to the loaded shift once the days are in. + if (startDay.HasValue && shift.StartDay != startDay.Value) + await _shiftsService.UpdateShiftStartDayAsync(shift, startDay.Value, cancellationToken); + var number = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(DepartmentId); _eventAggregator.SendMessage(new ShiftDaysAddedEvent() { DepartmentId = DepartmentId, DepartmentNumber = number, Item = shift }); @@ -446,6 +508,9 @@ public async Task EditShiftGroups(int shiftId) var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + if (shift == null) + return RedirectToAction("Index"); + if (shift.DepartmentId != DepartmentId) return Unauthorized(); @@ -460,8 +525,14 @@ public async Task EditShiftGroups(int shiftId) [Authorize(Policy = ResgridResources.Shift_Update)] public async Task EditShiftGroups(EditShiftView model, IFormCollection form, CancellationToken cancellationToken) { + if (model?.Shift == null) + return RedirectToAction("Index"); + var shift = await _shiftsService.GetShiftByIdAsync(model.Shift.ShiftId); + if (shift == null) + return RedirectToAction("Index"); + if (shift.DepartmentId != DepartmentId) return Unauthorized(); @@ -537,6 +608,9 @@ public async Task DeleteShift(int shiftId, CancellationToken canc { var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + if (shift == null) + return RedirectToAction("Index"); + if (shift.DepartmentId != DepartmentId) return Unauthorized(); @@ -552,10 +626,26 @@ public async Task Signup(int shiftDayId) var model = new ShiftSignupView(); model.Day = await _shiftsService.GetShiftDayByIdAsync(shiftDayId); + // GetShiftDayByIdAsync returns null for an unknown shiftDayId, and its Dapper mapping + // leaves Shift null when the join turns up no shift row. + if (model.Day?.Shift == null) + return RedirectToAction("Index"); + if (model.Day.Shift.DepartmentId != DepartmentId) return Unauthorized(); - model.Day.Shift = await _shiftsService.GetShiftByIdAsync(model.Day.ShiftId); + // Only swap in the fuller shift load when it succeeds; otherwise keep the one the day + // already carries rather than nulling out a shift we just authorized against. + var fullShift = await _shiftsService.GetShiftByIdAsync(model.Day.ShiftId); + + if (fullShift != null) + model.Day.Shift = fullShift; + + // Only GetShiftByIdAsync populates Groups; the shift carried by the day comes from a + // Dapper multi-map that leaves it null, and the view iterates it unguarded. + if (model.Day.Shift.Groups == null) + model.Day.Shift.Groups = new List(); + model.Roles = await _personnelRolesService.GetRolesForUserAsync(UserId, DepartmentId); model.Needs = await _shiftsService.GetShiftDayNeedsAsync(shiftDayId); model.Signups = await _shiftsService.GetShiftSignpsForShiftDayAsync(shiftDayId); @@ -569,9 +659,12 @@ public async Task Signup(int shiftDayId) model.UserSignedUp = false; } - foreach (var shiftGroup in model.Day.Shift.Groups) + if (model.Day.Shift.Groups != null) { - model.ShiftGroupSignups.Add(shiftGroup.DepartmentGroupId, await _shiftsService.IsUserSignedUpForShiftDayAsync(model.Day, UserId, shiftGroup.DepartmentGroupId)); + foreach (var shiftGroup in model.Day.Shift.Groups) + { + model.ShiftGroupSignups.Add(shiftGroup.DepartmentGroupId, await _shiftsService.IsUserSignedUpForShiftDayAsync(model.Day, UserId, shiftGroup.DepartmentGroupId)); + } } model.PersonnelRoles = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(DepartmentId); @@ -587,10 +680,21 @@ public async Task ViewShift(int shiftDayId) var model = new ShiftSignupView(); model.Day = await _shiftsService.GetShiftDayByIdAsync(shiftDayId); + if (model.Day?.Shift == null) + return RedirectToAction("Index"); + if (model.Day.Shift.DepartmentId != DepartmentId) return Unauthorized(); - model.Day.Shift = await _shiftsService.GetShiftByIdAsync(model.Day.ShiftId); //await _shiftsService.PopulateShiftData(model.Day.Shift, true, true, true, true, true); + var fullShift = await _shiftsService.GetShiftByIdAsync(model.Day.ShiftId); //await _shiftsService.PopulateShiftData(model.Day.Shift, true, true, true, true, true); + + if (fullShift != null) + model.Day.Shift = fullShift; + + // Only GetShiftByIdAsync populates Groups; the shift carried by the day comes from a + // Dapper multi-map that leaves it null, and the view iterates it unguarded. + if (model.Day.Shift.Groups == null) + model.Day.Shift.Groups = new List(); model.Roles = await _personnelRolesService.GetRolesForUserAsync(UserId, DepartmentId); model.Needs = await _shiftsService.GetShiftDayNeedsAsync(shiftDayId); @@ -608,11 +712,17 @@ public async Task ShiftDaySignup(int shiftDayId, int groupId, Can { var day = await _shiftsService.GetShiftDayByIdAsync(shiftDayId); + if (day?.Shift == null) + return RedirectToAction("Index"); + if (day.Shift.DepartmentId != DepartmentId) return Unauthorized(); var signup = await _shiftsService.SignupForShiftDayAsync(day.ShiftId, day.Day, groupId, UserId, cancellationToken); + if (signup == null) + return RedirectToAction("Signup", new { shiftDayId = shiftDayId }); + return RedirectToAction("SignupSuccess", new { shiftSignupId = signup.ShiftSignupId }); } @@ -647,8 +757,15 @@ public async Task SignupSuccess(int shiftSignupId) { var model = new ShiftSignupView(); model.Signup = await _shiftsService.GetShiftSignupByIdAsync(shiftSignupId); + + if (model.Signup == null) + return RedirectToAction("YourShifts"); + model.Signup.Shift = await _shiftsService.GetShiftByIdAsync(model.Signup.ShiftId); + if (model.Signup.Shift == null || model.Signup.Shift.DepartmentId != DepartmentId) + return RedirectToAction("YourShifts"); + return View(model); } @@ -670,7 +787,20 @@ public async Task DeclineShiftDay(int shiftSignupId, Cancellation { var signup = await _shiftsService.GetShiftSignupByIdAsync(shiftSignupId); - if (signup.Shift.DepartmentId != DepartmentId) + if (signup == null) + return RedirectToAction("YourShifts"); + + // GetShiftSignupByIdAsync only populates Trade, never Shift, so the shift has to be + // fetched separately the way DeleteShiftDaySignup and SignupSuccess already do. + var shift = await _shiftsService.GetShiftByIdAsync(signup.ShiftId); + + if (shift == null) + return RedirectToAction("YourShifts"); + + if (shift.DepartmentId != DepartmentId) + return Unauthorized(); + + if (!(await _authorizationService.CanUserDeleteShiftSignupAsync(UserId, DepartmentId, shiftSignupId))) return Unauthorized(); await _shiftsService.DeleteShiftSignupAsync(signup, cancellationToken); @@ -685,6 +815,9 @@ public async Task ProcessTrade(int shiftSignupTradeId) var model = new ProcessTradeView(); model.Trade = await _shiftsService.GetShiftTradeByIdAsync(shiftSignupTradeId); + if (model.Trade == null) + return RedirectToAction("YourShifts"); + return View(model); } @@ -701,8 +834,14 @@ public async Task ProcessTrade(ProcessTradeView model, IFormColle if (form.ContainsKey("note")) note = form["note"]; + if (model?.Trade == null) + return RedirectToAction("YourShifts"); + var tradeRequest = await _shiftsService.GetShiftTradeByIdAsync(model.Trade.ShiftSignupTradeId); + if (tradeRequest == null) + return RedirectToAction("YourShifts"); + if (dates != null && dates.Any()) await _shiftsService.ProposeShiftDaysForTradeAsync(tradeRequest.ShiftSignupTradeId, UserId, note, dates.Select(x => int.Parse(x)).ToList(), cancellationToken); else @@ -747,13 +886,39 @@ public async Task RejectTrade(int shiftTradeId, string reason, Ca return RedirectToAction("YourShifts"); } + // Finishing a trade is the source signup owner picking which offer to accept; YourShifts only + // renders the link on the caller's own signups. GetShiftTradeByIdAsync goes through + // RepositoryBase.GetByIdAsync, which populates no navigation properties, so the source signup + // and its shift have to be loaded to check ownership and department. + private async Task CanUserFinishTradeAsync(ShiftSignupTrade trade) + { + if (trade == null) + return false; + + var sourceSignup = trade.SourceShiftSignup ?? await _shiftsService.GetShiftSignupByIdAsync(trade.SourceShiftSignupId); + + if (sourceSignup == null || !String.Equals(sourceSignup.UserId, UserId, StringComparison.OrdinalIgnoreCase)) + return false; + + var sourceShift = await _shiftsService.GetShiftByIdAsync(sourceSignup.ShiftId); + + return sourceShift != null && sourceShift.DepartmentId == DepartmentId; + } + [HttpGet] [Authorize(Policy = ResgridResources.Shift_View)] public async Task FinishTrade(int shiftSignupTradeId) { var model = new FinishTradeView(); model.Trade = await _shiftsService.GetShiftTradeByIdAsync(shiftSignupTradeId); - model.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(model.Trade.Users.Select(x => x.UserId).ToList()); + + if (model.Trade == null) + return RedirectToAction("YourShifts"); + + if (!await CanUserFinishTradeAsync(model.Trade)) + return Unauthorized(); + + model.Profiles = await _userProfileService.GetSelectedUserProfilesAsync((model.Trade.Users ?? new List()).Select(x => x.UserId).ToList()); return View(model); } @@ -766,21 +931,61 @@ public async Task FinishTrade(FinishTradeView model, IFormCollect if (form.ContainsKey("selectedShift")) selectedShift = form["selectedShift"]; + if (model?.Trade == null) + return RedirectToAction("YourShifts"); + var tradeRequest = await _shiftsService.GetShiftTradeByIdAsync(model.Trade.ShiftSignupTradeId); + if (tradeRequest == null) + return RedirectToAction("YourShifts"); + + // Authorize against the trade loaded from the database, never the one posted in the form. + if (!await CanUserFinishTradeAsync(tradeRequest)) + return Unauthorized(); + if (selectedShift != null) { Guid userId; + // Everything below is driven by a raw form value, so each branch has to resolve back to + // a participant this trade actually has on record. GetShiftTradeByIdAsync already loads + // Users along with each user's offered Shifts, so no extra round trip is needed. + var offeredUsers = (tradeRequest.Users ?? new List()) + .Where(x => x.Offered && !x.Declined) + .ToList(); + if (Guid.TryParse(selectedShift, out userId)) { - tradeRequest.UserId = userId.ToString(); + // Unbalanced trade: the accepted user must be a participant who offered. + var acceptedUserId = userId.ToString(); + + if (!offeredUsers.Any(x => String.Equals(x.UserId, acceptedUserId, StringComparison.OrdinalIgnoreCase))) + return RedirectToAction("YourShifts"); + + tradeRequest.UserId = acceptedUserId; } else { - tradeRequest.TargetShiftSignupId = int.Parse(selectedShift); - var shiftSignup = await _shiftsService.GetShiftSignupByIdAsync(tradeRequest.TargetShiftSignupId.Value); - userId = Guid.Parse(shiftSignup.UserId); + // selectedShift comes straight off the form, so it is not necessarily a number. + if (!int.TryParse(selectedShift, out var targetShiftSignupId)) + return RedirectToAction("YourShifts"); + + // Without this the form could point the trade at any signup id in the system rather + // than one a participant actually put up for this trade. + var isOfferedShift = offeredUsers + .Where(x => x.Shifts != null) + .SelectMany(x => x.Shifts) + .Any(x => x.ShiftSignupId == targetShiftSignupId); + + if (!isOfferedShift) + return RedirectToAction("YourShifts"); + + var shiftSignup = await _shiftsService.GetShiftSignupByIdAsync(targetShiftSignupId); + + if (shiftSignup == null || !Guid.TryParse(shiftSignup.UserId, out userId)) + return RedirectToAction("YourShifts"); + + tradeRequest.TargetShiftSignupId = targetShiftSignupId; } var shiftTradeFilled = new ShiftTradeFilledEvent(); @@ -799,7 +1004,7 @@ public async Task FinishTrade(FinishTradeView model, IFormCollect } model.Trade = tradeRequest; - model.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(model.Trade.Users.Select(x => x.UserId).ToList()); + model.Profiles = await _userProfileService.GetSelectedUserProfilesAsync((model.Trade.Users ?? new List()).Select(x => x.UserId).ToList()); model.Message = "You must select a shift to trade for or accept an unbalanced trade"; return View(model); @@ -1309,8 +1514,13 @@ public async Task GetShiftJson(int shiftId) { var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + if (shift == null) + return NotFound(); + + // Returning null from an IActionResult action throws in MVC, so this branch used to + // surface as a 500 rather than a denial. if (shift.DepartmentId != DepartmentId) - return null; + return Unauthorized(); shift = await _shiftsService.PopulateShiftData(shift, true, true, true, true, true); @@ -1347,14 +1557,30 @@ public async Task GetPersonnelNotOnShiftDay(int shiftSignupId, in { var usersJson = new List(); var signup = await _shiftsService.GetShiftSignupByIdAsync(shiftSignupId); + + if (signup == null) + return Json(usersJson); + + // GetShiftSignupByIdAsync only populates Trade, so signup.Shift is always null here and + // the group lookup below has to work off a separately loaded shift. + var signupShift = await _shiftsService.GetShiftByIdAsync(signup.ShiftId); + + if (signupShift == null || signupShift.DepartmentId != DepartmentId) + return Json(usersJson); + var signups = await _shiftsService.GetShiftSignpsForShiftDayAsync(shiftDayId); var personnel = await _departmentService.GetAllUsersForDepartmentAsync(DepartmentId); var roles = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(DepartmentId); var rolesForSignup = new List(); - if (roles.ContainsKey(signup.UserId)) - rolesForSignup.AddRange(signup.Shift.Groups.First(x => x.DepartmentGroupId == signup.DepartmentGroupId).Roles.Where(x => roles[signup.UserId].Select(z => z.PersonnelRoleId).Contains(x.PersonnelRoleId))); + if (roles.ContainsKey(signup.UserId) && signupShift.Groups != null) + { + var signupGroup = signupShift.Groups.FirstOrDefault(x => x.DepartmentGroupId == signup.DepartmentGroupId); + + if (signupGroup?.Roles != null) + rolesForSignup.AddRange(signupGroup.Roles.Where(x => roles[signup.UserId].Select(z => z.PersonnelRoleId).Contains(x.PersonnelRoleId))); + } foreach (var user in personnel.Select(x => x.UserId).Except(signups.Select(y => y.UserId))) { @@ -1384,6 +1610,16 @@ public async Task GetShiftDaysUserIsOn(int shiftTradeId) { var shiftDayJson = new List(); var trade = await _shiftsService.GetShiftTradeByIdAsync(shiftTradeId); + + if (trade == null) + return Json(shiftDayJson); + + // GetShiftTradeByIdAsync leaves SourceShiftSignup null, so it has to be loaded by id. + var sourceSignup = trade.SourceShiftSignup ?? await _shiftsService.GetShiftSignupByIdAsync(trade.SourceShiftSignupId); + + if (sourceSignup == null) + return Json(shiftDayJson); + var signups = await _shiftsService.GetShiftSignupsForUserAsync(UserId); //var validShiftDays = from signup in signups @@ -1394,15 +1630,18 @@ public async Task GetShiftDaysUserIsOn(int shiftTradeId) // select signup; var validShiftDays = new List(); - foreach (var signup in signups) + + // GetShiftSignupsForUserAsync appends SourceShiftSignup off each unbalanced trade, and + // those can come back null. + foreach (var signup in signups.Where(x => x != null)) { var shiftDaySignups = await _shiftsService.GetShiftSignpsForShiftDayAsync(signup.ShiftSignupId); - if (!(from d in shiftDaySignups select d.UserId).Contains(trade.SourceShiftSignup.UserId)) + if (!(from d in shiftDaySignups select d.UserId).Contains(sourceSignup.UserId)) validShiftDays.Add(signup); } - foreach (var day in validShiftDays) + foreach (var day in validShiftDays.Where(x => x.Shift != null)) { var shiftDay = new ShiftDayJson(); shiftDay.ShiftSignupId = day.ShiftSignupId; diff --git a/Web/Resgrid.Web/Areas/User/Views/Shifts/FinishTrade.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shifts/FinishTrade.cshtml index a4c369b3a..0bc52f88c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shifts/FinishTrade.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shifts/FinishTrade.cshtml @@ -297,6 +297,14 @@ { var profile = Model.Profiles?.FirstOrDefault(p => p.UserId == tradeUser.UserId); var userShift = tradeUser.Shifts?.FirstOrDefault(); + + // The controller reads one "selectedShift" value and discriminates on its shape: + // an offered signup id for a balanced swap, or the participant's user id for an + // unbalanced trade where nothing is given back. + var offeredShiftSignupId = userShift?.ShiftSignupId; + var selectedShiftValue = offeredShiftSignupId.HasValue + ? offeredShiftSignupId.Value.ToString() + : tradeUser.UserId; @(profile != null ? profile.FullName.AsFirstNameLastName : tradeUser.UserId) @@ -320,7 +328,18 @@ } - + @if (tradeUser.Offered && !tradeUser.Declined) + { + + } + else if (tradeUser.Declined) + { + @localizer["Declined"] + } + else + { + @localizer["NoneValue"] + } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Shifts/Signup.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shifts/Signup.cshtml index 5040246e1..32cc8fd57 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shifts/Signup.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shifts/Signup.cshtml @@ -6,6 +6,26 @@ ViewBag.Title = "Resgrid | " + localizer["ShiftSignupHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; } +@functions { + // Signups outlive department membership, so a signup can name someone who is no longer in + // UserProfiles. Indexing straight into the dictionary turns that into a 500 on the page. + string GetSignupPersonName(string userId) + { + return Model.UserProfiles != null && Model.UserProfiles.TryGetValue(userId, out var profile) + ? profile.FullName.AsFirstNameLastName + : commonLocalizer["Unknown"].Value; + } + + // PersonnelRoles only contains members who have at least one role assigned, and an entry can + // hold nulls when a role row points at a role that no longer exists. + string GetSignupRoleNames(string userId) + { + if (Model.PersonnelRoles == null || !Model.PersonnelRoles.TryGetValue(userId, out var roles) || roles == null) + return String.Empty; + + return String.Join(", ", roles.Where(x => x != null).Select(x => x.Name)); + } +} @section Styles { @@ -152,12 +172,9 @@ @foreach (var signup in Model.Signups.Where(x => x.DepartmentGroupId == group.DepartmentGroupId)) { - var person = Model.UserProfiles[signup.UserId]; - var roles = Model.PersonnelRoles[signup.UserId]; - - @person.FullName.AsFirstNameLastName - @string.Join(", ", roles.Select(x => x.Name)) + @GetSignupPersonName(signup.UserId) + @GetSignupRoleNames(signup.UserId) @if (ClaimsAuthorizationHelper.IsUserDepartmentOrGroupAdmin(group.DepartmentGroupId) || ClaimsAuthorizationHelper.GetUserId() == signup.UserId) { @@ -178,24 +195,20 @@ @foreach (var signup in Model.Signups.Where(x => x.DepartmentGroupId == group.DepartmentGroupId && (x.Trade != null && x.Trade.IsTradeComplete()))) { - var person = Model.UserProfiles[signup.UserId]; - UserProfile person2; + var personName = GetSignupPersonName(signup.UserId); string message = String.Empty; if (!String.IsNullOrWhiteSpace(signup.Trade.UserId)) { - person2 = Model.UserProfiles[signup.Trade.UserId]; - message = String.Format(localizer["TradedWithFormat"].Value, person.FullName.AsFirstNameLastName, person2.FullName.AsFirstNameLastName); + message = String.Format(localizer["TradedWithFormat"].Value, personName, GetSignupPersonName(signup.Trade.UserId)); } - else if (signup.GetTradeType() == ShiftTradeTypes.Source) + else if (signup.GetTradeType() == ShiftTradeTypes.Source && signup.Trade.TargetShiftSignup != null) { - person2 = Model.UserProfiles[signup.Trade.TargetShiftSignup.UserId]; - message = String.Format(localizer["TradedWithFormat"].Value, person.FullName.AsFirstNameLastName, person2.FullName.AsFirstNameLastName); + message = String.Format(localizer["TradedWithFormat"].Value, personName, GetSignupPersonName(signup.Trade.TargetShiftSignup.UserId)); } - else if (signup.GetTradeType() == ShiftTradeTypes.Target) + else if (signup.GetTradeType() == ShiftTradeTypes.Target && signup.Trade.SourceShiftSignup != null) { - person2 = Model.UserProfiles[signup.Trade.SourceShiftSignup.UserId]; - message = String.Format(localizer["TradedWithFormat"].Value, person2.FullName.AsFirstNameLastName, person.FullName.AsFirstNameLastName); + message = String.Format(localizer["TradedWithFormat"].Value, GetSignupPersonName(signup.Trade.SourceShiftSignup.UserId), personName); } diff --git a/Web/Resgrid.Web/Areas/User/Views/Shifts/ViewShift.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shifts/ViewShift.cshtml index ed52ad79e..1125643f0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shifts/ViewShift.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shifts/ViewShift.cshtml @@ -6,6 +6,26 @@ ViewBag.Title = "Resgrid | " + localizer["ViewShiftHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; } +@functions { + // Signups outlive department membership, so a signup can name someone who is no longer in + // UserProfiles. Indexing straight into the dictionary turns that into a 500 on the page. + string GetSignupPersonName(string userId) + { + return Model.UserProfiles != null && Model.UserProfiles.TryGetValue(userId, out var profile) + ? profile.FullName.AsFirstNameLastName + : commonLocalizer["Unknown"].Value; + } + + // PersonnelRoles only contains members who have at least one role assigned, and an entry can + // hold nulls when a role row points at a role that no longer exists. + string GetSignupRoleNames(string userId) + { + if (Model.PersonnelRoles == null || !Model.PersonnelRoles.TryGetValue(userId, out var roles) || roles == null) + return String.Empty; + + return String.Join(", ", roles.Where(x => x != null).Select(x => x.Name)); + } +} @section Styles { @@ -152,11 +172,9 @@ @foreach (var signup in Model.Signups.Where(x => x.DepartmentGroupId == group.DepartmentGroupId)) { - var person = Model.UserProfiles[signup.UserId]; - var roles = Model.PersonnelRoles[signup.UserId]; - @person.FullName.AsFirstNameLastName - @string.Join(", ", roles.Select(x => x.Name)) + @GetSignupPersonName(signup.UserId) + @GetSignupRoleNames(signup.UserId) } @@ -171,24 +189,20 @@ @foreach (var signup in Model.Signups.Where(x => x.DepartmentGroupId == group.DepartmentGroupId && (x.Trade != null && x.Trade.IsTradeComplete()))) { - var person = Model.UserProfiles[signup.UserId]; - UserProfile person2; + var personName = GetSignupPersonName(signup.UserId); string message = String.Empty; if (!String.IsNullOrWhiteSpace(signup.Trade.UserId)) { - person2 = Model.UserProfiles[signup.Trade.UserId]; - message = String.Format(localizer["TradedWithFormat"].Value, person.FullName.AsFirstNameLastName, person2.FullName.AsFirstNameLastName); + message = String.Format(localizer["TradedWithFormat"].Value, personName, GetSignupPersonName(signup.Trade.UserId)); } - else if (signup.GetTradeType() == ShiftTradeTypes.Source) + else if (signup.GetTradeType() == ShiftTradeTypes.Source && signup.Trade.TargetShiftSignup != null) { - person2 = Model.UserProfiles[signup.Trade.TargetShiftSignup.UserId]; - message = String.Format(localizer["TradedWithFormat"].Value, person.FullName.AsFirstNameLastName, person2.FullName.AsFirstNameLastName); + message = String.Format(localizer["TradedWithFormat"].Value, personName, GetSignupPersonName(signup.Trade.TargetShiftSignup.UserId)); } - else if (signup.GetTradeType() == ShiftTradeTypes.Target) + else if (signup.GetTradeType() == ShiftTradeTypes.Target && signup.Trade.SourceShiftSignup != null) { - person2 = Model.UserProfiles[signup.Trade.SourceShiftSignup.UserId]; - message = String.Format(localizer["TradedWithFormat"].Value, person2.FullName.AsFirstNameLastName, person.FullName.AsFirstNameLastName); + message = String.Format(localizer["TradedWithFormat"].Value, GetSignupPersonName(signup.Trade.SourceShiftSignup.UserId), personName); }