word-waltz / files / src / firebase / gameService.ts
gameService.ts
| 1 | /** |
| 2 | * firebase/gameService.ts — Firestore game document CRUD |
| 3 | * |
| 4 | * All ciphertext/iv data written to Firestore must be encrypted by the caller |
| 5 | * before calling these functions. All data read from Firestore must be |
| 6 | * decrypted by the caller after these functions return. |
| 7 | * |
| 8 | * Every write also stores `lastPlayed: Date.now()` as an unencrypted field. |
| 9 | * This is the ONLY plaintext data in the document and is used solely for |
| 10 | * automated cleanup of stale games. It reveals no game content. |
| 11 | * |
| 12 | * Push notification fields (pushSubs, pendingNotification) are also plaintext |
| 13 | * and are described in types.ts. They reveal no game content — only that a |
| 14 | * player is subscribed to notifications and that it is someone's turn. |
| 15 | * |
| 16 | * None of these functions know about encryption — that is strictly the |
| 17 | * responsibility of the layer above (main.ts / route handlers). |
| 18 | */ |
| 19 | |
| 20 | import { doc, getDoc, setDoc, updateDoc, deleteDoc, onSnapshot } from 'firebase/firestore'; |
| 21 | import { db } from './config.js'; |
| 22 | import type { Encrypted, EncryptedEnvelope, PendingNotification, PlayerId } from '../types.js'; |
| 23 | |
| 24 | /** Create a new game document. */ |
| 25 | export async function createGame(gameId: string, encrypted: Encrypted): Promise<void> { |
| 26 | const envelope: EncryptedEnvelope = { ...encrypted, lastPlayed: Date.now() }; |
| 27 | await setDoc(doc(db, 'games', gameId), envelope); |
| 28 | } |
| 29 | |
| 30 | /** Read a game document once. Returns null if it does not exist. */ |
| 31 | export async function getGame(gameId: string): Promise<EncryptedEnvelope | null> { |
| 32 | const snap = await getDoc(doc(db, 'games', gameId)); |
| 33 | if (!snap.exists()) return null; |
| 34 | return snap.data() as EncryptedEnvelope; |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Overwrite the game document with new encrypted state. |
| 39 | * Always updates lastPlayed to the current time. |
| 40 | * Optionally writes a pendingNotification for the Cloud Function to consume. |
| 41 | */ |
| 42 | export async function updateGame( |
| 43 | gameId: string, |
| 44 | encrypted: Encrypted, |
| 45 | pendingNotification?: PendingNotification | null, |
| 46 | ): Promise<void> { |
| 47 | const update: Partial<EncryptedEnvelope> = { |
| 48 | ...encrypted, |
| 49 | lastPlayed: Date.now(), |
| 50 | pendingNotification: pendingNotification ?? null, |
| 51 | }; |
| 52 | await updateDoc(doc(db, 'games', gameId), update); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Store or clear this player's push subscription JSON in the game document. |
| 57 | * This is a partial update — it does not touch the encrypted game state. |
| 58 | */ |
| 59 | export async function storePushSubscription( |
| 60 | gameId: string, |
| 61 | player: PlayerId, |
| 62 | subscriptionJson: string | null, |
| 63 | ): Promise<void> { |
| 64 | await updateDoc(doc(db, 'games', gameId), { |
| 65 | [`pushSubs.${player}`]: subscriptionJson, |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | /** Subscribe to real-time updates for a game document. */ |
| 70 | export function subscribeToGame( |
| 71 | gameId: string, |
| 72 | onUpdate: (envelope: EncryptedEnvelope) => void, |
| 73 | ): () => void { |
| 74 | return onSnapshot(doc(db, 'games', gameId), (snap) => { |
| 75 | if (snap.exists()) onUpdate(snap.data() as EncryptedEnvelope); |
| 76 | }); |
| 77 | } |
| 78 | |
| 79 | /** Permanently delete a game document from Firestore. */ |
| 80 | export async function deleteGame(gameId: string): Promise<void> { |
| 81 | await deleteDoc(doc(db, 'games', gameId)); |
| 82 | } |
| 83 |