Skip to content
Closed
3 changes: 3 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/development-to-staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@

---

## Contributors
<!-- Credit anyone who contributed a PR in this batch besides yourself (e.g. @username). -->

## Technical notes
<!-- Migrations, env vars, feature flags, anything a reviewer needs to know -->
3 changes: 3 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/staging-to-main.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@

---

## Contributors
<!-- Credit anyone who contributed a PR in this release besides yourself (e.g. @username). -->

## Technical notes
<!-- Migrations, env vars, feature flags, rollout/rollback risk -->
7 changes: 6 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions docs/operations/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ This is a lightweight runbook for backend deployment and immediate verification.
- Prefer additive schema changes prior to destructive cleanups
- Announce deploy windows for higher-risk changes

### Removing a database column safely

`web`, `celery`, and `celery-beat` (and their `-staging` equivalents) are
separate Render services, each with its own `autoDeployTrigger: commit` and
build queue. Only `web` has a `preDeployCommand` that runs `migrate`; `celery`
and `celery-beat` have none. All three deploy independently off the same
commit, with no guarantee they finish building/restarting at the same time —
`celery`/`celery-beat` builds can lag `web` by anywhere from seconds to a
couple of hours depending on Render's build queue.

Because of this, a migration that drops a column in the same deploy as the
code change that stops using it creates a window where `web` has already
migrated the (shared) database but `celery`/`celery-beat` are still running
the *old* image — which still references the now-dropped column — until
their own deploys catch up. Any query touching that model from the old
worker code raises `UndefinedColumn` for the whole window. (This is what
happened with `Character.can_link` on 2026-08-14: the migration removing the
column landed in the same deploy as the code that stopped needing it, and
`commute_tick`/`wander_tick` errored on the stale `celery`/`celery-beat`
workers until they finished redeploying.)

To remove a column without a stale-worker error window, split it across two
separate deploys (expand/contract):

1. **Deploy 1**: ship the code change that stops reading/writing the column,
but leave the column itself in the DB (no migration removing it yet).
2. Confirm `web`, `celery`, and `celery-beat` (and `-staging` equivalents)
have all finished redeploying and are healthy.
3. **Deploy 2**: add the migration that drops the column, with no
accompanying code change needed (the code already stopped touching it in
deploy 1).

## Note on PR workflow

The rule for which branch a *feature* PR should target (normally `development`, with a documented exception for basing on `staging`) is a development workflow concern, not a deployment-execution step — that lives in `CLAUDE.md`, not here.
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
}

@include m.respond-to(sm, down) {
.row {
.row:not(.durationRow) {
flex-direction: column;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default function LogOfflineActivityModal({ onClose }: LogOfflineActivityM
<span className={styles.label}>
Duration <span className={styles.required} aria-label="required">*</span>
</span>
<div className={styles.row}>
<div className={classNames(styles.row, styles.durationRow)}>
<Input
id="offline-activity-duration-hours"
ariaLabel="Hours"
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/context/WebSocketContext.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

// ---------------------------------------------------------------------------
Expand All @@ -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<Set<(data: IncomingWebSocketMessage) => void>>(new Set());
const wsEnabled = Boolean(!authLoading && isAuthenticated && player?.id);
Expand All @@ -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');
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/types/timers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) */
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/websockets/handleGlobalWebSocketEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
switch (data.type) {
case 'notification':
Expand Down Expand Up @@ -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);
}
Expand Down