Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Client.React/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions Client.React/src/__tests__/NotificationsSettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('NotificationsSettingsPage', () => {

await waitFor(() => {
expect(mockedPutPreferences).toHaveBeenCalledWith(
'CFB',
expect.objectContaining({
notifyMineBloodyDuringGame: true,
notifyMineBloodyAtFinal: true,
Expand Down Expand Up @@ -131,6 +132,7 @@ describe('NotificationsSettingsPage', () => {

await waitFor(() => {
expect(mockedPutPreferences).toHaveBeenCalledWith(
'CFB',
expect.objectContaining({
notifyMineBloodyDuringGame: false,
notifyMineBloodyAtFinal: false,
Expand Down Expand Up @@ -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(<NotificationsSettingsPage />);

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'));
Expand Down
10 changes: 6 additions & 4 deletions Client.React/src/api/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationPreferencesDto>('/api/notifications/preferences');
// Each app (NFL / CFB host) has its own notification settings.
export async function getNotificationPreferences(sport: SportType) {
const { data } = await http.get<NotificationPreferencesDto>('/api/notifications/preferences', { params: { sport: toLeagueType(sport) } });
return data;
}

export async function putNotificationPreferences(payload: NotificationPreferencesDto) {
const { data } = await http.put<NotificationPreferencesDto>('/api/notifications/preferences', payload);
export async function putNotificationPreferences(sport: SportType, payload: NotificationPreferencesDto) {
const { data } = await http.put<NotificationPreferencesDto>('/api/notifications/preferences', payload, { params: { sport: toLeagueType(sport) } });
return data;
}

Expand Down
11 changes: 8 additions & 3 deletions Client.React/src/pages/account/NotificationsSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -229,7 +229,12 @@ export default function NotificationsSettingsPage() {

return (
<Stack spacing={3} sx={{ maxWidth: 640, margin: '0 auto', paddingTop: 6 }}>
<Typography variant="h5">Notifications</Typography>
<div>
<Typography variant="h5">Notifications</Typography>
<Typography variant="body2" color="text.secondary">
These settings only apply to the {sport === 'CFB' ? 'college football' : 'NFL'} app — each IV League app has its own.
</Typography>
</div>

<Card>
<CardContent>
Expand Down
4 changes: 2 additions & 2 deletions Client.React/src/services/push.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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 ?? '',
Expand Down
3 changes: 3 additions & 0 deletions Client.React/src/services/sport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 28 additions & 14 deletions Server.UnitTests/NotificationDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LeagueType>()).Returns(new NotificationPreferencesDto { NotifyWeekResult = false });

await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production")
.DispatchAsync("user-1", p => p.NotifyWeekResult, new PushPayload("T", "B"));
Expand All @@ -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<LeagueType>()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true });
_subscriptionService.GetForUserAsync("user-1").Returns([]);

await BuildDispatcher(isDevelopment: false, railwayEnvironmentName: "production")
Expand All @@ -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<LeagueType>()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true });
var subscriptions = OneSubscription();
_subscriptionService.GetForUserAsync("user-1").Returns(subscriptions);

Expand All @@ -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<LeagueType>()).Returns(new NotificationPreferencesDto { NotifyWeekResult = true });
_subscriptionService.GetForUserAsync("user-1").Returns(OneSubscription());

await BuildDispatcher(isDevelopment, railwayEnvironmentName)
Expand All @@ -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<PushSubscription> 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 },
Expand All @@ -101,32 +100,47 @@ private static List<PushSubscription> 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<LeagueType>()).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<PushSubscription>(s => s.Endpoint == expected), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
await _pushSender.Received(1).SendAsync(Arg.Is<PushSubscription>(s => s.Endpoint == "https://push.example/old"), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
await _pushSender.DidNotReceive().SendAsync(Arg.Is<PushSubscription>(s => s.Endpoint == "https://push.example/old"), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
await _pushSender.DidNotReceive().SendAsync(Arg.Is<PushSubscription>(s => s.Endpoint == excluded), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
}

// 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<LeagueType>()).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 },
]);

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<PushSubscription>(s => s.Endpoint == "https://push.example/cfb"), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
await _pushSender.DidNotReceive().SendAsync(Arg.Any<PushSubscription>(), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
}

[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<PushSubscription>(), Arg.Is<PushPayload>(p => p.Body == "nfl"), Arg.Any<CancellationToken>());
await _pushSender.DidNotReceive().SendAsync(Arg.Any<PushSubscription>(), Arg.Is<PushPayload>(p => p.Body == "college"), Arg.Any<CancellationToken>());
}
}
43 changes: 33 additions & 10 deletions Server.UnitTests/NotificationPreferencesServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FourPlayWebApp.Shared.Models.Enum;
using FourPlayWebApp.Server.Services;
using FourPlayWebApp.Shared.Models.Data.Dtos;
using Microsoft.EntityFrameworkCore;
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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);
});
Expand All @@ -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);

Expand All @@ -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);
});
}
Expand All @@ -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());
});
Expand All @@ -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);
});
}
}
Loading
Loading