diff --git a/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md b/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md index ef9219d5..f8ea009d 100644 --- a/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md +++ b/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md @@ -6,7 +6,7 @@ Leave a category empty (or delete its header) if nothing applies. --> -## User-visible improvements (UVIs) +## Summary ### Features - @@ -21,7 +21,3 @@ ## Technical notes - -## Test plan -- [ ] CI passes -- [ ] Smoke test on staging after deploy diff --git a/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md b/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md index c27096c0..d7eec391 100644 --- a/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md +++ b/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md @@ -21,7 +21,3 @@ ## Technical notes - -## Test plan -- [ ] CI passes -- [ ] Verified on staging diff --git a/character/models/character.py b/character/models/character.py index 053c8573..2bb024db 100644 --- a/character/models/character.py +++ b/character/models/character.py @@ -333,7 +333,7 @@ def linkable(self): ) -class CharacterManager(models.Manager.from_queryset(CharacterQuerySet)): +class CharacterManager(models.Manager.from_queryset(CharacterQuerySet)): # type: ignore[misc] pass diff --git a/core/admin.py b/core/admin.py index 87c83e5b..bdc0cfc7 100644 --- a/core/admin.py +++ b/core/admin.py @@ -133,7 +133,12 @@ def has_add_permission(self, request): @admin.action(description="Publish selected announcements") def publish_selected_announcements(_modeladmin, _request, queryset): now = timezone.now() - queryset.update(is_published=True, published_at=now) + # Save individually (not queryset.update()) so Announcement.save() + # broadcasts the "announcement_published" WebSocket event per row. + for announcement in queryset: + announcement.is_published = True + announcement.published_at = now + announcement.save() @admin.action(description="Unpublish selected announcements") diff --git a/core/models.py b/core/models.py index da57cd33..0bb0ecff 100644 --- a/core/models.py +++ b/core/models.py @@ -247,6 +247,33 @@ class Meta: def __str__(self): return self.title + def save(self, *args, **kwargs): + was_published = ( + Announcement.objects.filter(pk=self.pk, is_published=True).exists() + if self.pk + else False + ) + super().save(*args, **kwargs) + if self.is_published and not was_published: + from django.db import transaction + + transaction.on_commit(self._broadcast_published) + + def _broadcast_published(self): + from asgiref.sync import async_to_sync + + from gameplay.utils import send_group_message + + async_to_sync(send_group_message)( + "online_users", + { + "type": "action", + "action": "announcement_published", + "data": {"id": self.id}, + "success": True, + }, + ) + class PlayerAnnouncementState(models.Model): player = models.ForeignKey( diff --git a/frontend/src/context/WebSocketContext.tsx b/frontend/src/context/WebSocketContext.tsx index aabc3b7d..6de4e5af 100644 --- a/frontend/src/context/WebSocketContext.tsx +++ b/frontend/src/context/WebSocketContext.tsx @@ -1,6 +1,7 @@ // context/WebSocketContext.tsx import { useRef, useCallback, useEffect } from 'react'; import type { ReactNode, ReactElement } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useGame } from '../hooks/useGame'; import { useOnlineCount } from './OnlineCountContext'; import { useToast } from '../hooks/useToast'; @@ -10,6 +11,10 @@ import { handleGlobalWebSocketEvent } from '../websockets/handleGlobalWebSocketE import { useMaintenanceStatus } from '../hooks/useMaintenanceStatus'; import { useMaintenanceContext } from './MaintenanceContext'; import { WebSocketContext } from './webSocketContext'; +import { + ANNOUNCEMENTS_QUERY_KEY, + ANNOUNCEMENT_UNREAD_QUERY_KEY, +} from '../hooks/useAnnouncements'; import type { ActivityTimerApiData, IncomingWebSocketMessage, OutgoingWebSocketMessage } from '../types'; // --------------------------------------------------------------------------- @@ -36,6 +41,7 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement => const { showToast } = useToast(); const { refetch: maintenanceRefetch } = useMaintenanceStatus(); const { setMaintenance } = useMaintenanceContext(); + const queryClient = useQueryClient(); // Set stores message handler callbacks registered by child components const eventHandlersRef = useRef void>>(new Set()); const wsEnabled = Boolean(!authLoading && isAuthenticated && player?.id); @@ -52,14 +58,25 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement => }); }, [loadFromServer, player?.is_premium, freeTimerLimitSeconds]); + const onAnnouncementPublished = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ANNOUNCEMENTS_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: ANNOUNCEMENT_UNREAD_QUERY_KEY }); + }, [queryClient]); + const onMessage = useCallback((data: IncomingWebSocketMessage) => { if (data.type === 'online_count') { setOnlinePlayerCount(data.count); } //console.log("[WS Provider] showToast:", showToast); - handleGlobalWebSocketEvent(data, { showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate }); + handleGlobalWebSocketEvent(data, { + showToast, + maintenanceRefetch, + setMaintenance, + onActivityTimerUpdate, + onAnnouncementPublished, + }); eventHandlersRef.current.forEach((handler) => handler(data)); - }, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate]); + }, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate, onAnnouncementPublished]); const onError = useCallback(() => { console.error('WebSocket connection error'); diff --git a/frontend/src/types/timers.ts b/frontend/src/types/timers.ts index 6dca6d7e..6ac00197 100644 --- a/frontend/src/types/timers.ts +++ b/frontend/src/types/timers.ts @@ -157,7 +157,7 @@ export interface WebSocketErrorMessage extends WebSocketMessageBase { /** Server-initiated action message (maintenance refresh, game events) */ export interface WebSocketActionMessage { type: "action"; - action: "refresh" | "load-game" | "activity_timer_update"; + action: "refresh" | "load-game" | "activity_timer_update" | "announcement_published"; message?: string; maintenance_active?: boolean; name?: string; @@ -168,8 +168,12 @@ export interface WebSocketActionMessage { * Present when action is "activity_timer_update" — pushed whenever another * of this player's sessions (tabs/devices) starts, labels, or submits the * activity timer, so every open session can reconcile to server state. + * + * Present when action is "announcement_published" — the id of the + * newly-published Announcement, so callers can invalidate the + * announcements list and unread-count queries. */ - data?: { activity_timer: ActivityTimerApiData }; + data?: { activity_timer: ActivityTimerApiData } | { id: number }; } /** Generic server message (currently unused payload) */ diff --git a/frontend/src/websockets/handleGlobalWebSocketEvent.ts b/frontend/src/websockets/handleGlobalWebSocketEvent.ts index a12599a2..140d54d2 100644 --- a/frontend/src/websockets/handleGlobalWebSocketEvent.ts +++ b/frontend/src/websockets/handleGlobalWebSocketEvent.ts @@ -13,11 +13,22 @@ interface HandleGlobalWebSocketEventOptions { * useActivityTimer's loadFromServer. */ onActivityTimerUpdate?: (activityTimer: ActivityTimerApiData) => void; + /** + * Called when the server announces a newly-published Announcement, so the + * caller can refetch the announcements list / unread-count queries. + */ + onAnnouncementPublished?: () => void; } export async function handleGlobalWebSocketEvent( data: IncomingWebSocketMessage, - { showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate }: HandleGlobalWebSocketEventOptions, + { + showToast, + maintenanceRefetch, + setMaintenance, + onActivityTimerUpdate, + onAnnouncementPublished, + }: HandleGlobalWebSocketEventOptions, ): Promise { switch (data.type) { case 'notification': @@ -74,10 +85,13 @@ export async function handleGlobalWebSocketEvent( console.log("[WS] Django consumer 'load-game' message not currently in use."); break; case 'activity_timer_update': - if (data.data?.activity_timer) { + if (data.data && 'activity_timer' in data.data) { onActivityTimerUpdate?.(data.data.activity_timer); } break; + case 'announcement_published': + onAnnouncementPublished?.(); + break; default: console.warn('[WS] Unknown action:', data); }