Summary
Replace Player.last_season integer column with a register_playerseason table holding full membership history (one row per player per season).
Phase 1: Add New Model + Schema
1.1 Django model
- File:
backend/register/models.py (after PlayerHandicap)
- Add
PlayerSeason model: player FK + season int, unique constraint (player, season)
- Keep
last_season on Player for now (removed in Phase 5)
1.2 Django migration
- Run
makemigrations register to create the new table
1.3 Django admin
- File:
backend/register/admin.py
- Add
PlayerSeasonInline (TabularInline) to PlayerAdmin.inlines
1.4 Django serializer
- File:
backend/register/serializers.py
- Replace
last_season with computed is_returning_member (SerializerMethodField) in SimplePlayerSerializer and PlayerSerializer
- Logic:
PlayerSeason.objects.filter(player=obj, season=current_season()-1).exists()
1.5 Drizzle schema
- File:
apps/api/src/database/schema/registration.schema.ts
- Add
playerSeason table: id, playerId FK, season int, unique constraint
1.6 Domain type
- File:
packages/domain/src/types/register/player.ts
- Remove
lastSeason?: number | null
- Add
isReturningMember?: boolean
Phase 2: SQL Backfill Script
2.1 Create backfill script
- File:
backend/sql/BackfillPlayerSeasons.sql (new)
INSERT INTO register_playerseason (player_id, season)
SELECT DISTINCT rs.player_id, e.season
FROM register_registrationslot rs
JOIN events_event e ON rs.event_id = e.id
WHERE e.event_type = 'R'
AND rs.status = 'R'
AND rs.player_id IS NOT NULL
ON DUPLICATE KEY UPDATE season = VALUES(season);
Phase 3: API Layer Updates (NestJS)
3.1 Repository methods
- File:
apps/api/src/registration/repositories/registration.repository.ts
- Add:
createPlayerSeason(playerId, season), deletePlayerSeason(playerId, season), isReturningMember(playerId, season) (checks row exists for season-1)
3.2 Update updateMembershipStatus
- File:
apps/api/src/registration/services/admin-registration.service.ts
- Replace
player.lastSeason = season with repository.createPlayerSeason(player.id, season)
- Still set
player.isMember = 1
3.3 Update player drop-registration
- File:
apps/api/src/registration/services/player.service.ts
- When dropping season registration: call
repository.deletePlayerSeason(playerId, season) and unset isMember
3.4 Update mail service
- File:
apps/api/src/mail/mail.service.ts
- Replace
player.lastSeason === currentYear - 1 with precomputed isReturningMember boolean passed from the calling service
3.5 Update mapper
- File:
apps/api/src/registration/mappers.ts
- Remove
lastSeason mapping, add isReturningMember (computed from PlayerSeason or passed through)
3.6 Stripe webhook
- No direct change needed — already calls
updateMembershipStatus (updated in 3.2)
Phase 4: Frontend Updates
4.1 Public Player model
- File:
apps/public/src/models/player.ts
- Remove
last_season from zod schemas
- Source
isReturningMember from API field is_returning_member instead of computing from last_season
4.2 Registration context
- File:
apps/public/src/context/registration-context-provider.tsx
- Replace
last_season: currentSeason - 1 with is_returning_member: true in optimistic cache update
4.3 Report utils
- File:
apps/public/src/components/reports/report-utils.ts
- Replace
obj.last_season == currentSeason - 1 with obj.is_returning_member (requires SP update below)
4.4 Season event detail + club event model
- No changes needed — already read
player.isReturningMember property
Phase 5: Django Backend Updates + Cleanup
5.1 Update derive_notification_type
- File:
backend/payments/utils.py
- Replace
player.last_season == (season - 1) with PlayerSeason.objects.filter(player=player, season=season-1).exists()
5.2 Update Django tests
- File:
backend/payments/tests/test_utils.py
- Replace mock
last_season attributes with PlayerSeason fixtures
5.3 Update SQL stored procedures
- File:
backend/sql/PostRestoreUpdates.sql
GetFriends, SearchPlayers: remove p.last_season from SELECT
MembershipReport: replace rp.last_season with LEFT JOIN to register_playerseason
5.4 Domain payment function
- File:
packages/domain/src/functions/payment.ts
- Update
deriveNotificationType — callers pass isReturningMember: boolean instead of playerLastSeason: number | null
5.5 Remove last_season from Player
backend/register/models.py — delete last_season field
backend/register/admin.py — remove from fields and list_filter
apps/api/src/database/schema/registration.schema.ts — remove lastSeason column
5.6 Final Django migration
- Run
makemigrations register to drop last_season column
5.7 Update NestJS tests
- All test files with mock
lastSeason on player objects:
apps/api/src/registration/__tests__/admin-registration.service.test.ts
apps/api/src/registration/__tests__/player.service.test.ts
apps/api/src/registration/__tests__/registration.service.test.ts
apps/api/src/registration/__tests__/payments.service.test.ts
apps/api/src/mail/__tests__/mail.service.test.ts
apps/api/src/stripe/__tests__/stripe-webhook.service.test.ts
Verification
- Run Django migrations:
uv run python manage.py migrate
- Run backfill script against dev database
- Run Django tests:
uv run python manage.py test
- Run API tests:
pnpm --filter api test
docker compose up -d --build and verify:
- Season registration flow creates PlayerSeason row
- Drop registration removes PlayerSeason row
- Returning member fees display correctly
- Membership report shows correct returning/new status
Summary
Replace
Player.last_seasoninteger column with aregister_playerseasontable holding full membership history (one row per player per season).Phase 1: Add New Model + Schema
1.1 Django model
backend/register/models.py(afterPlayerHandicap)PlayerSeasonmodel:playerFK +seasonint, unique constraint(player, season)last_seasonon Player for now (removed in Phase 5)1.2 Django migration
makemigrations registerto create the new table1.3 Django admin
backend/register/admin.pyPlayerSeasonInline(TabularInline) toPlayerAdmin.inlines1.4 Django serializer
backend/register/serializers.pylast_seasonwith computedis_returning_member(SerializerMethodField) inSimplePlayerSerializerandPlayerSerializerPlayerSeason.objects.filter(player=obj, season=current_season()-1).exists()1.5 Drizzle schema
apps/api/src/database/schema/registration.schema.tsplayerSeasontable:id,playerIdFK,seasonint, unique constraint1.6 Domain type
packages/domain/src/types/register/player.tslastSeason?: number | nullisReturningMember?: booleanPhase 2: SQL Backfill Script
2.1 Create backfill script
backend/sql/BackfillPlayerSeasons.sql(new)Phase 3: API Layer Updates (NestJS)
3.1 Repository methods
apps/api/src/registration/repositories/registration.repository.tscreatePlayerSeason(playerId, season),deletePlayerSeason(playerId, season),isReturningMember(playerId, season)(checks row exists for season-1)3.2 Update
updateMembershipStatusapps/api/src/registration/services/admin-registration.service.tsplayer.lastSeason = seasonwithrepository.createPlayerSeason(player.id, season)player.isMember = 13.3 Update player drop-registration
apps/api/src/registration/services/player.service.tsrepository.deletePlayerSeason(playerId, season)and unsetisMember3.4 Update mail service
apps/api/src/mail/mail.service.tsplayer.lastSeason === currentYear - 1with precomputedisReturningMemberboolean passed from the calling service3.5 Update mapper
apps/api/src/registration/mappers.tslastSeasonmapping, addisReturningMember(computed from PlayerSeason or passed through)3.6 Stripe webhook
updateMembershipStatus(updated in 3.2)Phase 4: Frontend Updates
4.1 Public Player model
apps/public/src/models/player.tslast_seasonfrom zod schemasisReturningMemberfrom API fieldis_returning_memberinstead of computing fromlast_season4.2 Registration context
apps/public/src/context/registration-context-provider.tsxlast_season: currentSeason - 1withis_returning_member: truein optimistic cache update4.3 Report utils
apps/public/src/components/reports/report-utils.tsobj.last_season == currentSeason - 1withobj.is_returning_member(requires SP update below)4.4 Season event detail + club event model
player.isReturningMemberpropertyPhase 5: Django Backend Updates + Cleanup
5.1 Update
derive_notification_typebackend/payments/utils.pyplayer.last_season == (season - 1)withPlayerSeason.objects.filter(player=player, season=season-1).exists()5.2 Update Django tests
backend/payments/tests/test_utils.pylast_seasonattributes withPlayerSeasonfixtures5.3 Update SQL stored procedures
backend/sql/PostRestoreUpdates.sqlGetFriends,SearchPlayers: removep.last_seasonfrom SELECTMembershipReport: replacerp.last_seasonwith LEFT JOIN toregister_playerseason5.4 Domain payment function
packages/domain/src/functions/payment.tsderiveNotificationType— callers passisReturningMember: booleaninstead ofplayerLastSeason: number | null5.5 Remove
last_seasonfrom Playerbackend/register/models.py— deletelast_seasonfieldbackend/register/admin.py— remove fromfieldsandlist_filterapps/api/src/database/schema/registration.schema.ts— removelastSeasoncolumn5.6 Final Django migration
makemigrations registerto droplast_seasoncolumn5.7 Update NestJS tests
lastSeasonon player objects:apps/api/src/registration/__tests__/admin-registration.service.test.tsapps/api/src/registration/__tests__/player.service.test.tsapps/api/src/registration/__tests__/registration.service.test.tsapps/api/src/registration/__tests__/payments.service.test.tsapps/api/src/mail/__tests__/mail.service.test.tsapps/api/src/stripe/__tests__/stripe-webhook.service.test.tsVerification
uv run python manage.py migrateuv run python manage.py testpnpm --filter api testdocker compose up -d --buildand verify: