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); }