From 0fa5ca215ca51a147556fafa8804ba8ee44eb7c0 Mon Sep 17 00:00:00 2001 From: frizat Date: Sun, 4 Oct 2026 15:47:33 -0500 Subject: [PATCH] Per-sport notification settings; alerts only go to their own sport's app Owner: each app controls its own notification settings ("I may want NFL and not college"), and notifications only go to the proper app for that sport. NotificationPreferences becomes one row per (user, sport); migration copies existing rows to CFB so nobody's alerts change. The dispatcher reads the alert sport's settings and sends only to that sport's app (reverts #463's fallback). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DAWjtJgXaMYbzZXYJjTEYs --- Client.React/CHANGELOG.md | 2 +- .../NotificationsSettingsPage.test.tsx | 11 + Client.React/src/api/notifications.ts | 10 +- .../account/NotificationsSettingsPage.tsx | 11 +- Client.React/src/services/push.ts | 4 +- Client.React/src/services/sport.tsx | 3 + .../NotificationDispatcherTests.cs | 42 +- .../NotificationPreferencesServiceTests.cs | 43 +- .../NotificationsControllerTests.cs | 25 +- Server/Controllers/NotificationsController.cs | 9 +- .../NotificationPreferencesConfiguration.cs | 2 +- ...erSportNotificationPreferences.Designer.cs | 1516 +++++++++++++++++ ...4204137_PerSportNotificationPreferences.cs | 56 + .../ApplicationDbContextModelSnapshot.cs | 5 +- Server/Models/Data/NotificationPreferences.cs | 3 + .../INotificationPreferencesService.cs | 5 +- Server/Services/NotificationDispatcher.cs | 15 +- .../NotificationPreferencesService.cs | 11 +- 18 files changed, 1713 insertions(+), 60 deletions(-) create mode 100644 Server/Migrations/20261004204137_PerSportNotificationPreferences.Designer.cs create mode 100644 Server/Migrations/20261004204137_PerSportNotificationPreferences.cs diff --git a/Client.React/CHANGELOG.md b/Client.React/CHANGELOG.md index 3a2274d5..29dacec5 100644 --- a/Client.React/CHANGELOG.md +++ b/Client.React/CHANGELOG.md @@ -6,7 +6,7 @@ Format: `## ` release headings and single-line `- ` bullets only — no bold, li ## 2026-10-04 -- Fixed: if you only have one IV League app installed (NFL or college), you now get alerts for both sports there instead of missing the other sport's alerts +- Changed: the NFL and college apps now each have their own notification settings, and each app only gets its own sport's alerts — turn on push in the NFL app for NFL alerts and in the college app for college alerts (your existing settings were copied to both) - Fixed: NFL notifications also arrived on the college app (and vice versa) when both were installed — each app now only gets its own sport's alerts - Fixed: the first week-result notification run of the season sent a push for every already-finished week (Weeks 1-4) — a week's result can now only be pushed within 48 hours of that week ending, never later - Improved: in dark mode, team logos switch to ESPN's dark-background versions (NFL and college), so logos with dark lettering like Ohio State's stay readable diff --git a/Client.React/src/__tests__/NotificationsSettingsPage.test.tsx b/Client.React/src/__tests__/NotificationsSettingsPage.test.tsx index 1535ab34..e40dbe51 100644 --- a/Client.React/src/__tests__/NotificationsSettingsPage.test.tsx +++ b/Client.React/src/__tests__/NotificationsSettingsPage.test.tsx @@ -93,6 +93,7 @@ describe('NotificationsSettingsPage', () => { await waitFor(() => { expect(mockedPutPreferences).toHaveBeenCalledWith( + 'CFB', expect.objectContaining({ notifyMineBloodyDuringGame: true, notifyMineBloodyAtFinal: true, @@ -131,6 +132,7 @@ describe('NotificationsSettingsPage', () => { await waitFor(() => { expect(mockedPutPreferences).toHaveBeenCalledWith( + 'CFB', expect.objectContaining({ notifyMineBloodyDuringGame: false, notifyMineBloodyAtFinal: false, @@ -163,6 +165,15 @@ describe('NotificationsSettingsPage', () => { expect(screen.getAllByRole('switch', { hidden: true })).toHaveLength(7); }); + it("loads this app's sport's own settings and says which sport they apply to", async () => { + mockedGetPreferences.mockResolvedValue(ALL_OFF); + + render(); + + await waitFor(() => expect(mockedGetPreferences).toHaveBeenCalledWith('CFB')); + expect(screen.getByText(/only apply to the college football app/i)).toBeInTheDocument(); + }); + it('shows an error toast when saving fails', async () => { mockedGetPreferences.mockResolvedValue(ALL_OFF); mockedPutPreferences.mockRejectedValue(new Error('network error')); diff --git a/Client.React/src/api/notifications.ts b/Client.React/src/api/notifications.ts index 10b4890b..1360f96e 100644 --- a/Client.React/src/api/notifications.ts +++ b/Client.React/src/api/notifications.ts @@ -6,14 +6,16 @@ import type { UnsubscribeRequestDto, VapidPublicKeyDto, } from '../types/notifications'; +import { toLeagueType, type SportType } from '../services/sport'; -export async function getNotificationPreferences() { - const { data } = await http.get('/api/notifications/preferences'); +// Each app (NFL / CFB host) has its own notification settings. +export async function getNotificationPreferences(sport: SportType) { + const { data } = await http.get('/api/notifications/preferences', { params: { sport: toLeagueType(sport) } }); return data; } -export async function putNotificationPreferences(payload: NotificationPreferencesDto) { - const { data } = await http.put('/api/notifications/preferences', payload); +export async function putNotificationPreferences(sport: SportType, payload: NotificationPreferencesDto) { + const { data } = await http.put('/api/notifications/preferences', payload, { params: { sport: toLeagueType(sport) } }); return data; } diff --git a/Client.React/src/pages/account/NotificationsSettingsPage.tsx b/Client.React/src/pages/account/NotificationsSettingsPage.tsx index 0f9b5acf..67fa86d5 100644 --- a/Client.React/src/pages/account/NotificationsSettingsPage.tsx +++ b/Client.React/src/pages/account/NotificationsSettingsPage.tsx @@ -108,7 +108,7 @@ export default function NotificationsSettingsPage() { resolver: zodResolver(schema), defaultValues: async () => { try { - return { ...DEFAULT_VALUES, ...(await getNotificationPreferences()) }; + return { ...DEFAULT_VALUES, ...(await getNotificationPreferences(sport)) }; } catch { return DEFAULT_VALUES; } @@ -186,7 +186,7 @@ export default function NotificationsSettingsPage() { const onSubmit = async (values: FormValues) => { try { - await putNotificationPreferences(values); + await putNotificationPreferences(sport, values); toast.push('Notification preferences saved', 'success'); } catch { toast.push('Error saving notification preferences', 'error'); @@ -229,7 +229,12 @@ export default function NotificationsSettingsPage() { return ( - Notifications +
+ Notifications + + These settings only apply to the {sport === 'CFB' ? 'college football' : 'NFL'} app — each IV League app has its own. + +
diff --git a/Client.React/src/services/push.ts b/Client.React/src/services/push.ts index c86a49cc..627d88cd 100644 --- a/Client.React/src/services/push.ts +++ b/Client.React/src/services/push.ts @@ -1,7 +1,7 @@ import type { PushSubscriptionRequestDto } from '../types/notifications'; import { isStandalonePwa } from '../utils/pwa'; import { decodeBase64Url } from '../utils/base64'; -import type { SportType } from './sport'; +import { toLeagueType, type SportType } from './sport'; export function isPushSupported(): boolean { return ( @@ -63,7 +63,7 @@ export async function subscribeBrowserToPush( export function toSubscriptionRequest(subscription: PushSubscription, sport: SportType): PushSubscriptionRequestDto { const json = subscription.toJSON(); return { - sport: sport === 'CFB' ? 1 : 0, + sport: toLeagueType(sport), endpoint: subscription.endpoint, p256dh: json.keys?.p256dh ?? '', auth: json.keys?.auth ?? '', diff --git a/Client.React/src/services/sport.tsx b/Client.React/src/services/sport.tsx index e83a5880..7335adcf 100644 --- a/Client.React/src/services/sport.tsx +++ b/Client.React/src/services/sport.tsx @@ -2,6 +2,9 @@ import { createContext, useContext, useMemo } from 'react'; export type SportType = 'NFL' | 'CFB'; +/** The server's LeagueType enum value for a sport: 0 = NFL, 1 = CFB. */ +export const toLeagueType = (sport: SportType): 0 | 1 => (sport === 'CFB' ? 1 : 0); + interface SportsContextValue { sport: SportType; isCfb: boolean; diff --git a/Server.UnitTests/NotificationDispatcherTests.cs b/Server.UnitTests/NotificationDispatcherTests.cs index e4af7f2f..a3c8186b 100644 --- a/Server.UnitTests/NotificationDispatcherTests.cs +++ b/Server.UnitTests/NotificationDispatcherTests.cs @@ -40,7 +40,7 @@ private NotificationDispatcher BuildDispatcher(bool isDevelopment, string? railw [Fact] public async Task DispatchAsync_WhenToggleDisabled_NeverLoadsSubscriptions_NeverSends() { - _preferencesService.GetAsync("user-1").Returns(new NotificationPreferencesDto { NotifyWeekResult = false }); + _preferencesService.GetAsync("user-1", Arg.Any()).Returns(new NotificationPreferencesDto { NotifyWeekResult = false }); await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production") .DispatchAsync("user-1", p => p.NotifyWeekResult, new PushPayload("T", "B")); @@ -52,7 +52,7 @@ await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production" [Fact] public async Task DispatchAsync_WhenToggleEnabled_ButNoSubscribedDevices_DoesNotSend() { - _preferencesService.GetAsync("user-1").Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); + _preferencesService.GetAsync("user-1", Arg.Any()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); _subscriptionService.GetForUserAsync("user-1").Returns([]); await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production") @@ -64,7 +64,7 @@ await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production" [Fact] public async Task DispatchAsync_InProduction_SendsToEveryDevice() { - _preferencesService.GetAsync("user-1").Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); + _preferencesService.GetAsync("user-1", Arg.Any()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); var subscriptions = OneSubscription(); _subscriptionService.GetForUserAsync("user-1").Returns(subscriptions); @@ -80,7 +80,7 @@ await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production" [InlineData(true, null)] // local dev run: IsDevelopment()=true, no Railway var at all public async Task DispatchAsync_OutsideProduction_SuppressesSend_ButStillConsumesTheToggleCheck(bool isDevelopment, string? railwayEnvironmentName) { - _preferencesService.GetAsync("user-1").Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); + _preferencesService.GetAsync("user-1", Arg.Any()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); _subscriptionService.GetForUserAsync("user-1").Returns(OneSubscription()); await BuildDispatcher(isDevelopment, railwayEnvironmentName) @@ -90,8 +90,7 @@ await BuildDispatcher(isDevelopment, railwayEnvironmentName) } // Each installed app (NFL at ivleague.xyz, CFB at cfb.ivleague.xyz) registers its own - // subscription — an NFL alert must only reach the NFL app. A subscription saved before sport - // was tracked (null) keeps getting every sport, so nobody who enabled push loses anything. + // subscription — an NFL alert must only ever reach the NFL app. private static List NflCfbAndUnlabeled() => [ new() { Id = 1, UserId = "user-1", Endpoint = "https://push.example/nfl", P256dh = "p", Auth = "a", Sport = LeagueType.Nfl }, new() { Id = 2, UserId = "user-1", Endpoint = "https://push.example/cfb", P256dh = "p", Auth = "a", Sport = LeagueType.Cfb }, @@ -101,25 +100,25 @@ private static List NflCfbAndUnlabeled() => [ [Theory] [InlineData(LeagueType.Nfl, "https://push.example/nfl", "https://push.example/cfb")] [InlineData(LeagueType.Cfb, "https://push.example/cfb", "https://push.example/nfl")] - public async Task DispatchAsync_SendsOnlyToThatSportsApp_PlusUnlabeledSubscriptions(LeagueType sport, string expected, string excluded) + public async Task DispatchAsync_SendsOnlyToThatSportsApp(LeagueType sport, string expected, string excluded) { - _preferencesService.GetAsync("user-1").Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); + _preferencesService.GetAsync("user-1", Arg.Any()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); _subscriptionService.GetForUserAsync("user-1").Returns(NflCfbAndUnlabeled()); await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production") .DispatchAsync("user-1", p => p.NotifyWeekResult, new PushPayload("T", "B", Sport: sport)); await _pushSender.Received(1).SendAsync(Arg.Is(s => s.Endpoint == expected), Arg.Any(), Arg.Any()); - await _pushSender.Received(1).SendAsync(Arg.Is(s => s.Endpoint == "https://push.example/old"), Arg.Any(), Arg.Any()); + await _pushSender.DidNotReceive().SendAsync(Arg.Is(s => s.Endpoint == "https://push.example/old"), Arg.Any(), Arg.Any()); await _pushSender.DidNotReceive().SendAsync(Arg.Is(s => s.Endpoint == excluded), Arg.Any(), Arg.Any()); } - // Many users install only one app but are in both sports' leagues — a push must never be - // dropped just because the user has no app for that sport (a real NFL alert was, 2026-10-04). + // Owner's rule: notifications only go to the proper app for that sport — a user who only + // enabled push in the college app gets no NFL alerts there. [Fact] - public async Task DispatchAsync_UserWithOnlyTheOtherSportsApp_StillGetsThePush() + public async Task DispatchAsync_UserWithOnlyTheOtherSportsApp_DoesNotGetThePush() { - _preferencesService.GetAsync("user-1").Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); + _preferencesService.GetAsync("user-1", Arg.Any()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); _subscriptionService.GetForUserAsync("user-1").Returns([ new PushSubscription { Id = 1, UserId = "user-1", Endpoint = "https://push.example/cfb", P256dh = "p", Auth = "a", Sport = LeagueType.Cfb }, ]); @@ -127,6 +126,21 @@ public async Task DispatchAsync_UserWithOnlyTheOtherSportsApp_StillGetsThePush() await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production") .DispatchAsync("user-1", p => p.NotifyWeekResult, new PushPayload("T", "B", Sport: LeagueType.Nfl)); - await _pushSender.Received(1).SendAsync(Arg.Is(s => s.Endpoint == "https://push.example/cfb"), Arg.Any(), Arg.Any()); + await _pushSender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DispatchAsync_UsesTheSettingsForTheAlertsSport() + { + _preferencesService.GetAsync("user-1", LeagueType.Nfl).Returns(new NotificationPreferencesDto { NotifyWeekResult = true }); + _preferencesService.GetAsync("user-1", LeagueType.Cfb).Returns(new NotificationPreferencesDto { NotifyWeekResult = false }); + _subscriptionService.GetForUserAsync("user-1").Returns(NflCfbAndUnlabeled()); + var dispatcher = BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production"); + + await dispatcher.DispatchAsync("user-1", p => p.NotifyWeekResult, new PushPayload("T", "college", Sport: LeagueType.Cfb)); + await dispatcher.DispatchAsync("user-1", p => p.NotifyWeekResult, new PushPayload("T", "nfl", Sport: LeagueType.Nfl)); + + await _pushSender.Received(1).SendAsync(Arg.Any(), Arg.Is(p => p.Body == "nfl"), Arg.Any()); + await _pushSender.DidNotReceive().SendAsync(Arg.Any(), Arg.Is(p => p.Body == "college"), Arg.Any()); } } diff --git a/Server.UnitTests/NotificationPreferencesServiceTests.cs b/Server.UnitTests/NotificationPreferencesServiceTests.cs index 179c57a1..ae4c0e3b 100644 --- a/Server.UnitTests/NotificationPreferencesServiceTests.cs +++ b/Server.UnitTests/NotificationPreferencesServiceTests.cs @@ -1,3 +1,4 @@ +using FourPlayWebApp.Shared.Models.Enum; using FourPlayWebApp.Server.Services; using FourPlayWebApp.Shared.Models.Data.Dtos; using Microsoft.EntityFrameworkCore; @@ -15,7 +16,7 @@ await SqliteTestDb.WithDb(nameof(GetAsync_WhenNoRowExists_ReturnsAllOffDefaults) await using (var seed = SqliteTestDb.Open(nameof(GetAsync_WhenNoRowExists_ReturnsAllOffDefaults))) await SqliteTestDb.SeedUser(seed, "alice"); - var result = await new NotificationPreferencesService(factory).GetAsync("alice"); + var result = await new NotificationPreferencesService(factory).GetAsync("alice", LeagueType.Nfl); Assert.False(result.NotifyMineBloodyDuringGame); Assert.False(result.NotifyMineBloodyAtFinal); @@ -38,7 +39,7 @@ await SqliteTestDb.WithDb(db, async factory => await using (var seed = SqliteTestDb.Open(db)) await SqliteTestDb.SeedUser(seed, "alice"); - var saved = await new NotificationPreferencesService(factory).UpsertAsync("alice", new NotificationPreferencesDto + var saved = await new NotificationPreferencesService(factory).UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyMineCoveringAtFinal = true, NotifyWeekResult = true, @@ -48,7 +49,7 @@ await SqliteTestDb.WithDb(db, async factory => Assert.True(saved.NotifyWeekResult); Assert.False(saved.NotifyMineBloodyDuringGame); - var reread = await new NotificationPreferencesService(factory).GetAsync("alice"); + var reread = await new NotificationPreferencesService(factory).GetAsync("alice", LeagueType.Nfl); Assert.True(reread.NotifyMineCoveringAtFinal); Assert.True(reread.NotifyWeekResult); }); @@ -64,10 +65,10 @@ await SqliteTestDb.WithDb(db, async factory => await SqliteTestDb.SeedUser(seed, "alice"); var service = new NotificationPreferencesService(factory); - await service.UpsertAsync("alice", new NotificationPreferencesDto { NotifyMineBloodyAtFinal = true }); - await service.UpsertAsync("alice", new NotificationPreferencesDto { NotifyMineBloodyAtFinal = false, NotifyWeekResult = true }); + await service.UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyMineBloodyAtFinal = true }); + await service.UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyMineBloodyAtFinal = false, NotifyWeekResult = true }); - var result = await service.GetAsync("alice"); + var result = await service.GetAsync("alice", LeagueType.Nfl); Assert.False(result.NotifyMineBloodyAtFinal); Assert.True(result.NotifyWeekResult); @@ -89,9 +90,9 @@ await SqliteTestDb.WithDb(db, async factory => } var service = new NotificationPreferencesService(factory); - await service.UpsertAsync("alice", new NotificationPreferencesDto { NotifyWeekResult = true }); + await service.UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyWeekResult = true }); - var bobsPrefs = await service.GetAsync("bob"); + var bobsPrefs = await service.GetAsync("bob", LeagueType.Nfl); Assert.False(bobsPrefs.NotifyWeekResult); }); } @@ -108,7 +109,7 @@ await SqliteTestDb.WithDb(db, async factory => await SqliteTestDb.SeedUser(seed, "alice"); var service = new NotificationPreferencesService(factory); - await service.UpsertAsync("alice", new NotificationPreferencesDto { NotifyWeekResult = true }); + await service.UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyWeekResult = true }); Assert.False(await service.AnyLiveNotificationPreferenceEnabledAsync()); }); @@ -124,9 +125,31 @@ await SqliteTestDb.WithDb(db, async factory => await SqliteTestDb.SeedUser(seed, "alice"); var service = new NotificationPreferencesService(factory); - await service.UpsertAsync("alice", new NotificationPreferencesDto { NotifyOthersCoveringAtFinal = true }); + await service.UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyOthersCoveringAtFinal = true }); Assert.True(await service.AnyLiveNotificationPreferenceEnabledAsync()); }); } + + // Each app (NFL host / CFB host) has its own notification settings — "I may want NFL and not + // college" — saving one sport must never change the other. + [Fact] + public async Task UpsertAsync_PerSport_SavingNflNeverChangesCollege() + { + var db = nameof(UpsertAsync_PerSport_SavingNflNeverChangesCollege); + await SqliteTestDb.WithDb(db, async factory => + { + await using (var seed = SqliteTestDb.Open(db)) + await SqliteTestDb.SeedUser(seed, "alice"); + var service = new NotificationPreferencesService(factory); + + await service.UpsertAsync("alice", LeagueType.Cfb, new NotificationPreferencesDto { NotifyWeekResult = true }); + await service.UpsertAsync("alice", LeagueType.Nfl, new NotificationPreferencesDto { NotifyWeekResult = false, NotifyMineBloodyDuringGame = true }); + + Assert.True((await service.GetAsync("alice", LeagueType.Cfb)).NotifyWeekResult); + Assert.False((await service.GetAsync("alice", LeagueType.Cfb)).NotifyMineBloodyDuringGame); + Assert.False((await service.GetAsync("alice", LeagueType.Nfl)).NotifyWeekResult); + Assert.True((await service.GetAsync("alice", LeagueType.Nfl)).NotifyMineBloodyDuringGame); + }); + } } diff --git a/Server.UnitTests/NotificationsControllerTests.cs b/Server.UnitTests/NotificationsControllerTests.cs index 25c31893..03467639 100644 --- a/Server.UnitTests/NotificationsControllerTests.cs +++ b/Server.UnitTests/NotificationsControllerTests.cs @@ -34,13 +34,28 @@ [new Claim(ClaimTypes.NameIdentifier, userId)], "Test")), // ── Preferences ─────────────────────────────────────────────────────────── + [Fact] + public async Task Preferences_AreReadAndSavedForTheRequestedSport() + { + var dto = new NotificationPreferencesDto { NotifyWeekResult = true }; + _preferencesService.GetAsync("user-1", LeagueType.Cfb).Returns(dto); + _preferencesService.UpsertAsync("user-1", LeagueType.Cfb, dto).Returns(dto); + + await BuildController("user-1").GetPreferences(LeagueType.Cfb); + await BuildController("user-1").PutPreferences(LeagueType.Cfb, dto); + + await _preferencesService.Received(1).GetAsync("user-1", LeagueType.Cfb); + await _preferencesService.Received(1).UpsertAsync("user-1", LeagueType.Cfb, dto); + } + + [Fact] public async Task GetPreferences_ReturnsTheCallersPreferences() { var dto = new NotificationPreferencesDto { NotifyWeekResult = true }; - _preferencesService.GetAsync("user-1").Returns(dto); + _preferencesService.GetAsync("user-1", LeagueType.Nfl).Returns(dto); - var result = await BuildController("user-1").GetPreferences(); + var result = await BuildController("user-1").GetPreferences(LeagueType.Nfl); var ok = Assert.IsType(result.Result); Assert.Same(dto, ok.Value); @@ -50,12 +65,12 @@ public async Task GetPreferences_ReturnsTheCallersPreferences() public async Task PutPreferences_SavesForTheCaller_NeverAForeignUser() { var request = new NotificationPreferencesDto { NotifyMineCoveringAtFinal = true }; - _preferencesService.UpsertAsync("user-1", request).Returns(request); + _preferencesService.UpsertAsync("user-1", LeagueType.Nfl, request).Returns(request); - var result = await BuildController("user-1").PutPreferences(request); + var result = await BuildController("user-1").PutPreferences(LeagueType.Nfl, request); Assert.IsType(result.Result); - await _preferencesService.Received(1).UpsertAsync("user-1", request); + await _preferencesService.Received(1).UpsertAsync("user-1", LeagueType.Nfl, request); // No overload of UpsertAsync accepts a caller-supplied userId from the request body at // all — the only userId that can ever reach the service is CurrentUserId. } diff --git a/Server/Controllers/NotificationsController.cs b/Server/Controllers/NotificationsController.cs index 549f7afd..a753f657 100644 --- a/Server/Controllers/NotificationsController.cs +++ b/Server/Controllers/NotificationsController.cs @@ -1,3 +1,4 @@ +using FourPlayWebApp.Shared.Models.Enum; using System.Security.Claims; using FourPlayWebApp.Server.Auth; using FourPlayWebApp.Server.Services; @@ -20,15 +21,15 @@ public class NotificationsController( private string CurrentUserId => User.FindFirstValue(ClaimTypes.NameIdentifier)!; [HttpGet("preferences")] - public async Task> GetPreferences() + public async Task> GetPreferences([FromQuery] LeagueType sport = LeagueType.Nfl) { - return Ok(await preferencesService.GetAsync(CurrentUserId)); + return Ok(await preferencesService.GetAsync(CurrentUserId, sport)); } [HttpPut("preferences")] - public async Task> PutPreferences([FromBody] NotificationPreferencesDto preferences) + public async Task> PutPreferences([FromQuery] LeagueType sport, [FromBody] NotificationPreferencesDto preferences) { - return Ok(await preferencesService.UpsertAsync(CurrentUserId, preferences)); + return Ok(await preferencesService.UpsertAsync(CurrentUserId, sport, preferences)); } [HttpGet("vapid-public-key")] diff --git a/Server/Data/Configurations/NotificationPreferencesConfiguration.cs b/Server/Data/Configurations/NotificationPreferencesConfiguration.cs index 4b281cc3..02a53561 100644 --- a/Server/Data/Configurations/NotificationPreferencesConfiguration.cs +++ b/Server/Data/Configurations/NotificationPreferencesConfiguration.cs @@ -13,6 +13,6 @@ public void Configure(EntityTypeBuilder entity) .HasColumnType("timestamptz") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - entity.HasIndex(e => e.UserId).IsUnique(); + entity.HasIndex(e => new { e.UserId, e.Sport }).IsUnique(); } } diff --git a/Server/Migrations/20261004204137_PerSportNotificationPreferences.Designer.cs b/Server/Migrations/20261004204137_PerSportNotificationPreferences.Designer.cs new file mode 100644 index 00000000..131041c6 --- /dev/null +++ b/Server/Migrations/20261004204137_PerSportNotificationPreferences.Designer.cs @@ -0,0 +1,1516 @@ +// +using System; +using FourPlayWebApp.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FourPlayWebApp.Server.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20261004204137_PerSportNotificationPreferences")] + partial class PerSportNotificationPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.CfbRanking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CuratedRank") + .HasColumnType("integer"); + + b.Property("EspnEventId") + .HasColumnType("integer"); + + b.Property("EspnWeekNumber") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("TeamAbbreviation") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Season", "EspnWeekNumber", "TeamAbbreviation") + .IsUnique(); + + b.ToTable("CfbRankings"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.CfbSeasonWeekConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EspnWeekNumber") + .HasColumnType("integer"); + + b.Property("InScopeIvLeague") + .HasColumnType("boolean"); + + b.Property("IvLeagueWeekNumber") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScoringFormat") + .IsRequired() + .HasColumnType("text"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SpreadLockDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekEndDate") + .HasColumnType("date"); + + b.Property("WeekStartDate") + .HasColumnType("date"); + + b.Property("WeekType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Season", "EspnWeekNumber") + .IsUnique(); + + b.HasIndex("Season", "IvLeagueWeekNumber") + .IsUnique() + .HasFilter("\"IvLeagueWeekNumber\" <> 99"); + + b.ToTable("CfbSeasonWeekConfigs"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.CfbSlates", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("EspnWeekNumber") + .HasColumnType("integer"); + + b.Property("FirstGameUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScoringFormat") + .HasColumnType("text"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SlateNumber") + .HasColumnType("integer"); + + b.Property("SlateType") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("Season", "SlateNumber") + .IsUnique(); + + b.ToTable("CfbSlates"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeagueName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LeagueType") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("LeagueName") + .IsUnique(); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LeagueInfo"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueInviteLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("LeagueId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("LeagueInviteLinks"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueJuiceMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Juice") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(13); + + b.Property("JuiceConference") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(6); + + b.Property("JuiceDivisional") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(10); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartWeek") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeeklyCost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(5); + + b.HasKey("Id"); + + b.HasIndex("LeagueId", "Season") + .IsUnique(); + + b.ToTable("LeagueJuiceMapping"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueJuiceReminderSent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("LeagueId", "Season") + .IsUnique(); + + b.ToTable("LeagueJuiceReminderSent"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueMembershipInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvitedByUserId") + .HasColumnType("text"); + + b.Property("InvitedUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InvitedByUserId"); + + b.HasIndex("InvitedUserId"); + + b.HasIndex("LeagueId", "InvitedUserId") + .IsUnique(); + + b.ToTable("LeagueMembershipInvites"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueUserMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("LeagueId", "UserId") + .IsUnique(); + + b.ToTable("LeagueUserMapping"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.NflPicks", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("NflWeek") + .HasColumnType("integer"); + + b.Property("Pick") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("Team") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LeagueId"); + + b.HasIndex("UserId", "LeagueId", "NflWeek", "Season", "Team", "Pick") + .IsUnique(); + + b.ToTable("NflPicks"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.NflSeasonWeekConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FirstGameOfWeekStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("ScoringFormat") + .IsRequired() + .HasColumnType("text"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SpreadLockDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekEndDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekId") + .HasColumnType("integer"); + + b.Property("WeekLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("WeekNotes") + .HasColumnType("text"); + + b.Property("WeekStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("NflSeasonWeekConfigs"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.NflWeeks", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NflWeek") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Season", "NflWeek") + .IsUnique(); + + b.ToTable("NflWeeks"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.NotificationPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("NotifyMineBloodyAtFinal") + .HasColumnType("boolean"); + + b.Property("NotifyMineBloodyDuringGame") + .HasColumnType("boolean"); + + b.Property("NotifyMineCoveringAtFinal") + .HasColumnType("boolean"); + + b.Property("NotifyMineCoveringDuringGame") + .HasColumnType("boolean"); + + b.Property("NotifyOthersBloodyAtFinal") + .HasColumnType("boolean"); + + b.Property("NotifyOthersBloodyDuringGame") + .HasColumnType("boolean"); + + b.Property("NotifyOthersCoveringAtFinal") + .HasColumnType("boolean"); + + b.Property("NotifyOthersCoveringDuringGame") + .HasColumnType("boolean"); + + b.Property("NotifyWeekResult") + .HasColumnType("boolean"); + + b.Property("Sport") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Sport") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.PickLiveNotificationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FinalNotifiedAt") + .HasColumnType("timestamptz"); + + b.Property("LastNotifiedAt") + .HasColumnType("timestamptz"); + + b.Property("LastNotifiedCovering") + .HasColumnType("boolean"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("PickId") + .HasColumnType("integer"); + + b.Property("Sport") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("LeagueId"); + + b.HasIndex("Sport", "PickId") + .IsUnique(); + + b.ToTable("PickLiveNotificationStates"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamptz"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sport") + .HasColumnType("integer"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.TeamLiveNotificationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FinalNotifiedAt") + .HasColumnType("timestamptz"); + + b.Property("LastNotifiedAt") + .HasColumnType("timestamptz"); + + b.Property("LastNotifiedCovering") + .HasColumnType("boolean"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("Period") + .HasColumnType("integer"); + + b.Property("PickType") + .HasColumnType("integer"); + + b.Property("Sport") + .HasColumnType("integer"); + + b.Property("Team") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("Sport", "LeagueId", "Team", "PickType", "Period") + .IsUnique(); + + b.ToTable("TeamLiveNotificationStates"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.WeekResultNotificationSent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Week") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "LeagueId", "Season", "Week") + .IsUnique(); + + b.ToTable("WeekResultNotificationSent"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("EnableEmails") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Revoked") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvitationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvitedByUserId") + .HasColumnType("text"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("RegisteredUserId") + .HasColumnType("text"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"LeagueId\" IS NULL"); + + b.HasIndex("InvitedByUserId"); + + b.HasIndex("LeagueId"); + + b.HasIndex("RegisteredUserId"); + + b.HasIndex("Email", "LeagueId") + .IsUnique(); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.CfbPicks", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CfbSlateId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeagueId") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("Team") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CfbSlateId"); + + b.HasIndex("LeagueId"); + + b.HasIndex("UserId", "LeagueId", "CfbSlateId", "Season", "Team", "PickType") + .IsUnique(); + + b.ToTable("CfbPicks"); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.CfbScores", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwayTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwayTeamScore") + .HasColumnType("integer"); + + b.Property("CfbSlateId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("GameStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameTime") + .HasColumnType("timestamp with time zone"); + + b.Property("HomeTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("HomeTeamScore") + .HasColumnType("integer"); + + b.Property("WeatherConditionId") + .HasColumnType("text"); + + b.Property("WeatherDisplayValue") + .HasColumnType("text"); + + b.Property("WeatherTemperatureF") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CfbSlateId", "HomeTeam") + .IsUnique(); + + b.ToTable("CfbScores"); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.CfbSpreads", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwayTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwayTeamSpread") + .HasColumnType("double precision"); + + b.Property("CfbSlateId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("GameTime") + .HasColumnType("timestamp with time zone"); + + b.Property("HomeTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("HomeTeamSpread") + .HasColumnType("double precision"); + + b.Property("IsLeagueEligible") + .HasColumnType("boolean"); + + b.Property("OverUnder") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("CfbSlateId", "HomeTeam") + .IsUnique(); + + b.ToTable("CfbSpreads"); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.NflScores", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwayTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwayTeamScore") + .HasColumnType("integer"); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("GameTime") + .HasColumnType("timestamp with time zone"); + + b.Property("HomeTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("HomeTeamScore") + .HasColumnType("integer"); + + b.Property("NflWeek") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Season", "NflWeek", "HomeTeam") + .IsUnique(); + + b.ToTable("NflScores"); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.NflSpreads", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwayTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwayTeamSpread") + .HasColumnType("double precision"); + + b.Property("DateCreated") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("GameTime") + .HasColumnType("timestamp with time zone"); + + b.Property("HomeTeam") + .IsRequired() + .HasColumnType("text"); + + b.Property("HomeTeamSpread") + .HasColumnType("double precision"); + + b.Property("NflWeek") + .HasColumnType("integer"); + + b.Property("OverUnder") + .HasColumnType("double precision"); + + b.Property("Season") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Season", "NflWeek", "HomeTeam") + .IsUnique(); + + b.ToTable("NflSpreads"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueInfo", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueInviteLink", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", "League") + .WithMany() + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("League"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueJuiceMapping", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", "League") + .WithMany("LeagueJuiceMappings") + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("League"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueMembershipInvite", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "InvitedByUser") + .WithMany() + .HasForeignKey("InvitedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "InvitedUser") + .WithMany() + .HasForeignKey("InvitedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", "League") + .WithMany() + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InvitedByUser"); + + b.Navigation("InvitedUser"); + + b.Navigation("League"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueUserMapping", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", "League") + .WithMany("LeagueUserMappings") + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("League"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.NflPicks", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", "League") + .WithMany("NflPicks") + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("League"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Identity.RefreshToken", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Invitation", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "InvitedByUser") + .WithMany() + .HasForeignKey("InvitedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", "League") + .WithMany() + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", "RegisteredUser") + .WithMany() + .HasForeignKey("RegisteredUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("InvitedByUser"); + + b.Navigation("League"); + + b.Navigation("RegisteredUser"); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.CfbPicks", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Data.CfbSlates", null) + .WithMany() + .HasForeignKey("CfbSlateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Data.LeagueInfo", null) + .WithMany() + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.CfbScores", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Data.CfbSlates", null) + .WithMany() + .HasForeignKey("CfbSlateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FourPlayWebApp.Shared.Models.Data.CfbSpreads", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Data.CfbSlates", null) + .WithMany() + .HasForeignKey("CfbSlateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FourPlayWebApp.Server.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FourPlayWebApp.Server.Models.Data.LeagueInfo", b => + { + b.Navigation("LeagueJuiceMappings"); + + b.Navigation("LeagueUserMappings"); + + b.Navigation("NflPicks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Server/Migrations/20261004204137_PerSportNotificationPreferences.cs b/Server/Migrations/20261004204137_PerSportNotificationPreferences.cs new file mode 100644 index 00000000..256530bd --- /dev/null +++ b/Server/Migrations/20261004204137_PerSportNotificationPreferences.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FourPlayWebApp.Server.Migrations +{ + /// + public partial class PerSportNotificationPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_NotificationPreferences_UserId", + table: "NotificationPreferences"); + + migrationBuilder.AddColumn( + name: "Sport", + table: "NotificationPreferences", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_NotificationPreferences_UserId_Sport", + table: "NotificationPreferences", + columns: new[] { "UserId", "Sport" }, + unique: true); + + // Existing rows become NFL (default 0); copy each as a CFB (1) row so nobody's alerts + // change until they edit one sport's settings. + migrationBuilder.Sql(@"INSERT INTO ""NotificationPreferences"" (""Sport"", ""UserId"", ""NotifyMineBloodyDuringGame"", ""NotifyMineBloodyAtFinal"", ""NotifyMineCoveringDuringGame"", ""NotifyMineCoveringAtFinal"", ""NotifyOthersBloodyDuringGame"", ""NotifyOthersBloodyAtFinal"", ""NotifyOthersCoveringDuringGame"", ""NotifyOthersCoveringAtFinal"", ""NotifyWeekResult"", ""UpdatedAt"") + SELECT 1, ""UserId"", ""NotifyMineBloodyDuringGame"", ""NotifyMineBloodyAtFinal"", ""NotifyMineCoveringDuringGame"", ""NotifyMineCoveringAtFinal"", ""NotifyOthersBloodyDuringGame"", ""NotifyOthersBloodyAtFinal"", ""NotifyOthersCoveringDuringGame"", ""NotifyOthersCoveringAtFinal"", ""NotifyWeekResult"", ""UpdatedAt"" FROM ""NotificationPreferences"" WHERE ""Sport"" = 0;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@"DELETE FROM ""NotificationPreferences"" WHERE ""Sport"" <> 0;"); + + migrationBuilder.DropIndex( + name: "IX_NotificationPreferences_UserId_Sport", + table: "NotificationPreferences"); + + migrationBuilder.DropColumn( + name: "Sport", + table: "NotificationPreferences"); + + migrationBuilder.CreateIndex( + name: "IX_NotificationPreferences_UserId", + table: "NotificationPreferences", + column: "UserId", + unique: true); + } + } +} diff --git a/Server/Migrations/ApplicationDbContextModelSnapshot.cs b/Server/Migrations/ApplicationDbContextModelSnapshot.cs index 3eadf425..a4f97471 100644 --- a/Server/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Server/Migrations/ApplicationDbContextModelSnapshot.cs @@ -552,6 +552,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("NotifyWeekResult") .HasColumnType("boolean"); + b.Property("Sport") + .HasColumnType("integer"); + b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamptz") @@ -563,7 +566,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("UserId") + b.HasIndex("UserId", "Sport") .IsUnique(); b.ToTable("NotificationPreferences"); diff --git a/Server/Models/Data/NotificationPreferences.cs b/Server/Models/Data/NotificationPreferences.cs index 02e62109..39a324fa 100644 --- a/Server/Models/Data/NotificationPreferences.cs +++ b/Server/Models/Data/NotificationPreferences.cs @@ -1,3 +1,4 @@ +using FourPlayWebApp.Shared.Models.Enum; namespace FourPlayWebApp.Server.Models.Data; // One row per user, lazily created on first write (GetPreferencesAsync returns in-code all-off @@ -6,6 +7,8 @@ namespace FourPlayWebApp.Server.Models.Data; public class NotificationPreferences { public int Id { get; set; } public string UserId { get; set; } = string.Empty; + // One row per user per sport — each app (NFL / CFB host) has its own settings. + public LeagueType Sport { get; set; } public bool NotifyMineBloodyDuringGame { get; set; } public bool NotifyMineBloodyAtFinal { get; set; } diff --git a/Server/Services/Interfaces/INotificationPreferencesService.cs b/Server/Services/Interfaces/INotificationPreferencesService.cs index ea2f2bac..5947ac11 100644 --- a/Server/Services/Interfaces/INotificationPreferencesService.cs +++ b/Server/Services/Interfaces/INotificationPreferencesService.cs @@ -1,3 +1,4 @@ +using FourPlayWebApp.Shared.Models.Enum; using FourPlayWebApp.Shared.Models.Data.Dtos; namespace FourPlayWebApp.Server.Services.Interfaces; @@ -5,9 +6,9 @@ namespace FourPlayWebApp.Server.Services.Interfaces; public interface INotificationPreferencesService { /// Returns the user's saved preferences, or all-off defaults if no row exists yet. - Task GetAsync(string userId); + Task GetAsync(string userId, LeagueType sport); - Task UpsertAsync(string userId, NotificationPreferencesDto preferences); + Task UpsertAsync(string userId, LeagueType sport, NotificationPreferencesDto preferences); /// /// Cheap short-circuit for LivePickTransitionService: true if ANY user has ANY of the 8 live diff --git a/Server/Services/NotificationDispatcher.cs b/Server/Services/NotificationDispatcher.cs index 650b7980..1779f0d7 100644 --- a/Server/Services/NotificationDispatcher.cs +++ b/Server/Services/NotificationDispatcher.cs @@ -1,3 +1,4 @@ +using FourPlayWebApp.Shared.Models.Enum; using FourPlayWebApp.Server.Services.Interfaces; using FourPlayWebApp.Shared.Models.Data.Dtos; using Microsoft.AspNetCore.Hosting; @@ -19,17 +20,15 @@ public class NotificationDispatcher( // the pipe in any environment, not a real trigger this gate needs to protect against. public async Task DispatchAsync(string userId, Func isEnabled, PushPayload payload, CancellationToken cancellationToken = default) { - var preferences = await preferencesService.GetAsync(userId); + // Each sport has its own settings; every real trigger sets the payload's sport. + var preferences = await preferencesService.GetAsync(userId, payload.Sport ?? LeagueType.Nfl); if (!isEnabled(preferences)) return; - // Prefer the app for this push's sport (NFL vs CFB host), plus untagged devices, so a user - // with both apps gets it once, in the right one. A user with only the other sport's app - // still gets it there — a push is never dropped for lack of a matching app. - var allSubscriptions = await subscriptionService.GetForUserAsync(userId); - var matching = allSubscriptions - .Where(s => payload.Sport is null || s.Sport is null || s.Sport == payload.Sport) + // Only the app for this push's sport (NFL vs CFB host) — each app has its own settings and + // its own push permission, so an NFL alert never shows up in the college app or vice versa. + var subscriptions = (await subscriptionService.GetForUserAsync(userId)) + .Where(s => payload.Sport is null || s.Sport == payload.Sport) .ToList(); - var subscriptions = matching.Count > 0 ? matching : allSubscriptions; if (subscriptions.Count == 0) return; if (!DeploymentEnvironment.IsProduction(environment)) diff --git a/Server/Services/NotificationPreferencesService.cs b/Server/Services/NotificationPreferencesService.cs index f579a2f8..aca4bccd 100644 --- a/Server/Services/NotificationPreferencesService.cs +++ b/Server/Services/NotificationPreferencesService.cs @@ -1,3 +1,4 @@ +using FourPlayWebApp.Shared.Models.Enum; using FourPlayWebApp.Server.Data; using FourPlayWebApp.Server.Models.Data; using FourPlayWebApp.Server.Services.Interfaces; @@ -9,25 +10,25 @@ namespace FourPlayWebApp.Server.Services; public class NotificationPreferencesService(IDbContextFactory dbContextFactory) : INotificationPreferencesService { - public async Task GetAsync(string userId) + public async Task GetAsync(string userId, LeagueType sport) { await using var db = await dbContextFactory.CreateDbContextAsync(); var prefs = await db.NotificationPreferences.AsNoTracking() - .FirstOrDefaultAsync(p => p.UserId == userId); + .FirstOrDefaultAsync(p => p.UserId == userId && p.Sport == sport); // No row yet == every toggle defaults to off (pure opt-in) — not an error, the common // case for every user who has never visited the notifications settings page. return prefs is null ? new NotificationPreferencesDto() : ToDto(prefs); } - public async Task UpsertAsync(string userId, NotificationPreferencesDto preferences) + public async Task UpsertAsync(string userId, LeagueType sport, NotificationPreferencesDto preferences) { await using var db = await dbContextFactory.CreateDbContextAsync(); - var existing = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId); + var existing = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId && p.Sport == sport); if (existing is null) { - existing = new NotificationPreferences { UserId = userId }; + existing = new NotificationPreferences { UserId = userId, Sport = sport }; db.NotificationPreferences.Add(existing); }