diff --git a/apps/mobile/app/settings/notifications.tsx b/apps/mobile/app/settings/notifications.tsx index 85b14fa03..772ea21d4 100644 --- a/apps/mobile/app/settings/notifications.tsx +++ b/apps/mobile/app/settings/notifications.tsx @@ -8,27 +8,48 @@ import { type NotificationPreferences, setPreferences, } from '@services/notifications/notificationPreferences'; +import { useWalletStore } from '@store/useStore'; +import { cancelAllHuntExpiryNotifications } from '@utils/huntNotifications'; import React, { useCallback, useEffect, useState } from 'react'; -import { Linking, Platform,ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Linking, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'; export default function NotificationsScreen() { const { colors } = useTheme(); const { enabled, permissionStatus, loading, toggle } = useNotifications(); + const walletAddress = useWalletStore((state) => state.walletAddress); const [prefs, setPrefs] = useState(DEFAULT_PREFERENCES); - // Hydrate preferences from storage on mount + // Hydrate the local copy, then replace it with the wallet-scoped server + // copy. This makes the mobile screen reflect changes made on web or another + // phone while retaining offline support. useEffect(() => { - getPreferences().then(setPrefs); - }, []); + let cancelled = false; + getPreferences(walletAddress || undefined).then((nextPrefs) => { + if (!cancelled) setPrefs(nextPrefs); + }); + return () => { + cancelled = true; + }; + }, [walletAddress]); /** Persist a partial preference update. */ const updatePref = useCallback( async (key: keyof NotificationPreferences, value: boolean) => { const updated = { ...prefs, [key]: value }; setPrefs(updated); - await setPreferences(updated); + + if (!value && (key === 'enabled' || key === 'huntEvents')) { + try { + await cancelAllHuntExpiryNotifications(); + } catch { + // Preference changes should still persist if a scheduled reminder + // cannot be inspected on this device. + } + } + + await setPreferences(updated, walletAddress || undefined); }, - [prefs], + [prefs, walletAddress], ); /** Handle the master toggle — requests OS permission when enabling. */ @@ -67,17 +88,23 @@ export default function NotificationsScreen() { + {permissionStatus === 'denied' && ( + + )} {/* Hunt events */} diff --git a/apps/mobile/providers/NotificationsProvider.tsx b/apps/mobile/providers/NotificationsProvider.tsx index 842809c8b..818d93f25 100644 --- a/apps/mobile/providers/NotificationsProvider.tsx +++ b/apps/mobile/providers/NotificationsProvider.tsx @@ -16,7 +16,10 @@ */ import { incrementBadge, resetBadge } from '@services/notifications/badgeService'; -import { shouldShowNotification } from '@services/notifications/notificationPreferences'; +import { + getPreferences, + shouldShowNotification, +} from '@services/notifications/notificationPreferences'; import { configureNotificationHandler, ensureAndroidChannel, @@ -30,6 +33,8 @@ import { useRouter } from 'expo-router'; import React, { createContext, useContext, useEffect, useRef } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; +import { useWalletStore } from '@store/useStore'; + // ─── Context (exposed for convenience hooks) ────────────────────────────────── interface NotificationsContextValue { @@ -48,6 +53,14 @@ export function useNotificationsContext(): NotificationsContextValue { export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const router = useRouter(); const isListeningRef = useRef(false); + const walletAddress = useWalletStore((state) => state.walletAddress); + + // Hydrate the device cache when a wallet is restored or connected. The + // foreground handler can then apply the same preferences before the user + // opens the settings screen. + useEffect(() => { + void getPreferences(walletAddress || undefined); + }, [walletAddress]); useEffect(() => { // Configure handler as early as possible so foreground notifications show diff --git a/apps/mobile/services/notifications/notificationPreferences.ts b/apps/mobile/services/notifications/notificationPreferences.ts index b93a93f06..6d19b7656 100644 --- a/apps/mobile/services/notifications/notificationPreferences.ts +++ b/apps/mobile/services/notifications/notificationPreferences.ts @@ -1,13 +1,15 @@ /** * Notification preferences for Hunty Mobile. * - * Stores per-category notification preferences in AsyncStorage. Each category - * maps to one or more NotificationEventType values. The preferences are checked - * by the NotificationsProvider before displaying a foreground notification. + * The local copy keeps the app usable offline. When a wallet address is + * supplied, reads and writes also use the wallet-scoped v1 API so the same + * category choices are available on every device. */ import AsyncStorage from '@react-native-async-storage/async-storage'; +import env from '@config/env'; + import type { NotificationEventType } from './types'; const PREFS_KEY = 'hunty_notification_prefs'; @@ -36,41 +38,36 @@ export const DEFAULT_PREFERENCES: NotificationPreferences = { achievements: true, }; -// ─── Mapping from event type to preference category ─────────────────────────── - -const EVENT_TO_CATEGORY: Record< - NotificationEventType, - keyof Omit -> = { - hunt_start: 'huntEvents', - hunt_ending_soon: 'huntEvents', - reward: 'rewards', - correct_answer: 'rewards', - leaderboard_outranked: 'social', - achievement: 'achievements', -}; - -// ─── Public API ─────────────────────────────────────────────────────────────── +const PREFERENCES_ENDPOINT = `${env.apiUrl}/v1/notifications/preferences`; + +function normalizePreferences( + value: Partial | null | undefined, +): NotificationPreferences { + const input = value ?? {}; + return { + enabled: typeof input.enabled === 'boolean' ? input.enabled : DEFAULT_PREFERENCES.enabled, + huntEvents: + typeof input.huntEvents === 'boolean' ? input.huntEvents : DEFAULT_PREFERENCES.huntEvents, + rewards: typeof input.rewards === 'boolean' ? input.rewards : DEFAULT_PREFERENCES.rewards, + social: typeof input.social === 'boolean' ? input.social : DEFAULT_PREFERENCES.social, + achievements: + typeof input.achievements === 'boolean' + ? input.achievements + : DEFAULT_PREFERENCES.achievements, + }; +} -/** - * Retrieve the saved notification preferences, falling back to defaults. - */ -export async function getPreferences(): Promise { +async function getLocalPreferences(): Promise { try { const raw = await AsyncStorage.getItem(PREFS_KEY); if (!raw) return { ...DEFAULT_PREFERENCES }; - - const parsed = JSON.parse(raw) as Partial; - return { ...DEFAULT_PREFERENCES, ...parsed }; + return normalizePreferences(JSON.parse(raw) as Partial); } catch { return { ...DEFAULT_PREFERENCES }; } } -/** - * Persist notification preferences. - */ -export async function setPreferences(prefs: NotificationPreferences): Promise { +async function persistLocalPreferences(prefs: NotificationPreferences): Promise { try { await AsyncStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { @@ -78,14 +75,75 @@ export async function setPreferences(prefs: NotificationPreferences): Promise { + const local = await getLocalPreferences(); + if (!walletAddress) return local; + + try { + const response = await fetch( + `${PREFERENCES_ENDPOINT}?walletAddress=${encodeURIComponent(walletAddress)}`, + { headers: { Accept: 'application/json' } }, + ); + if (!response.ok) return local; + + const body = (await response.json()) as { + preferences?: Partial; + }; + if (!body.preferences) return local; + + const serverPreferences = normalizePreferences(body.preferences); + await persistLocalPreferences(serverPreferences); + return serverPreferences; + } catch { + // Offline or an unavailable API should not prevent local notifications. + return local; + } +} + /** - * Check whether a notification of the given type should be shown, based on the - * user's saved preferences. - * - * Returns false if: - * - The master toggle is off. - * - The category for the given type is disabled. - * - The event type is unknown (defensive fallback). + * Persist notification preferences locally and, when a wallet is supplied, + * sync the complete category document to the server. + */ +export async function setPreferences( + prefs: NotificationPreferences, + walletAddress?: string, +): Promise { + const normalized = normalizePreferences(prefs); + await persistLocalPreferences(normalized); + + if (!walletAddress) return; + + try { + await fetch(PREFERENCES_ENDPOINT, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress, preferences: normalized }), + }); + } catch { + // The local value remains available and will be retried on the next edit. + } +} + +// ─── Mapping from event type to preference category ─────────────────────────── + +const EVENT_TO_CATEGORY: Record< + NotificationEventType, + keyof Omit +> = { + hunt_start: 'huntEvents', + hunt_ending_soon: 'huntEvents', + reward: 'rewards', + correct_answer: 'rewards', + leaderboard_outranked: 'social', + achievement: 'achievements', +}; + +// ─── Public filtering API ───────────────────────────────────────────────────── + +/** + * Check whether a notification of the given type should be shown. + * Unknown event types are rejected defensively. */ export async function shouldShowNotification(type: string): Promise { const prefs = await getPreferences(); diff --git a/apps/mobile/services/notifications/notificationService.ts b/apps/mobile/services/notifications/notificationService.ts index 328d59f74..f48b7e41e 100644 --- a/apps/mobile/services/notifications/notificationService.ts +++ b/apps/mobile/services/notifications/notificationService.ts @@ -11,6 +11,7 @@ import * as Notifications from 'expo-notifications'; import * as TaskManager from 'expo-task-manager'; import { incrementBadge } from './badgeService'; +import { shouldShowNotification } from './notificationPreferences'; import type { NotificationPayload } from './types'; // ─── Background task ───────────────────────────────────────────────────────── @@ -31,8 +32,14 @@ TaskManager.defineTask(BACKGROUND_NOTIFICATION_TASK, async ({ data, error }) => } if (data) { - // Increment the badge count for every background notification - await incrementBadge(); + const notificationType = + typeof data === 'object' && data !== null && 'type' in data + ? String((data as { type: unknown }).type) + : null; + const shouldShow = notificationType ? await shouldShowNotification(notificationType) : true; + + // A muted notification should not increment the app badge either. + if (shouldShow) await incrementBadge(); } }); @@ -59,13 +66,18 @@ export async function registerBackgroundNotificationTask(): Promise { */ export function configureNotificationHandler(): void { Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }), + handleNotification: async (notification) => { + const payload = extractPayload(notification); + const shouldShow = payload ? await shouldShowNotification(payload.type) : true; + + return { + shouldShowAlert: shouldShow, + shouldPlaySound: shouldShow, + shouldSetBadge: shouldShow, + shouldShowBanner: shouldShow, + shouldShowList: shouldShow, + }; + }, }); } @@ -121,7 +133,7 @@ export async function requestPermission(): Promise { export function extractPayload( notification: Notifications.Notification, ): NotificationPayload | null { - const data = notification.request.content.data as Record | null; + const data = notification?.request?.content?.data as Record | null | undefined; if (!data || typeof data.type !== 'string') return null; return data as unknown as NotificationPayload; diff --git a/apps/mobile/utils/huntNotifications.ts b/apps/mobile/utils/huntNotifications.ts index b95555e80..8cfc0c0f4 100644 --- a/apps/mobile/utils/huntNotifications.ts +++ b/apps/mobile/utils/huntNotifications.ts @@ -1,5 +1,7 @@ import * as Notifications from 'expo-notifications'; +import { shouldShowNotification } from '@services/notifications/notificationPreferences'; + const NOTIF_ID_PREFIX = 'hunt_expiry_'; /** @@ -12,6 +14,12 @@ export async function scheduleHuntExpiryNotification( huntTitle: string, endTimeSeconds: number, ): Promise { + // Cancel an already scheduled reminder when the player mutes hunt events. + if (!(await shouldShowNotification('hunt_ending_soon'))) { + await cancelHuntExpiryNotification(huntId); + return; + } + const triggerAt = (endTimeSeconds - 3600) * 1000; // 1 hour before, in ms if (triggerAt <= Date.now()) return; @@ -32,3 +40,14 @@ export async function scheduleHuntExpiryNotification( export async function cancelHuntExpiryNotification(huntId: number): Promise { await Notifications.cancelScheduledNotificationAsync(`${NOTIF_ID_PREFIX}${huntId}`); } + +/** Cancel every locally scheduled hunt reminder when the category is muted. */ +export async function cancelAllHuntExpiryNotifications(): Promise { + const scheduled = await Notifications.getAllScheduledNotificationsAsync(); + await Promise.all( + scheduled + .map((notification) => notification.identifier) + .filter((identifier) => identifier.startsWith(NOTIF_ID_PREFIX)) + .map((identifier) => Notifications.cancelScheduledNotificationAsync(identifier)), + ); +} diff --git a/apps/web/app/api/hunts/schedule/route.ts b/apps/web/app/api/hunts/schedule/route.ts index 29af4240b..04b46443e 100644 --- a/apps/web/app/api/hunts/schedule/route.ts +++ b/apps/web/app/api/hunts/schedule/route.ts @@ -23,6 +23,7 @@ export async function POST() { return sendHuntStartReminder({ hunt, recipientEmail, + recipientWalletAddress: hunt.creator, startTime: hunt.startAt ?? hunt.startTime ?? Math.floor(Date.now() / 1000), }) }) diff --git a/apps/web/app/api/push-tokens/route.ts b/apps/web/app/api/push-tokens/route.ts index d1a2ffbd1..c8d8ab504 100644 --- a/apps/web/app/api/push-tokens/route.ts +++ b/apps/web/app/api/push-tokens/route.ts @@ -1,170 +1,137 @@ -import { createHash, randomBytes, timingSafeEqual } from "node:crypto" +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { NextRequest, NextResponse } from "next/server" -import { ForbiddenError, ValidationError } from "@/lib/api/errors" -import { withErrorHandling } from "@/lib/api/withErrorHandling" +import { NextRequest, NextResponse } from "next/server"; +import { ForbiddenError, ValidationError } from "@/lib/api/errors"; +import { withErrorHandling } from "@/lib/api/withErrorHandling"; import { getSubscriptionsForWallet, removeSubscriptionsForWallet, upsertSubscription, -} from "@/lib/notifications/subscriptionStore" +} from "@/lib/notifications/subscriptionStore"; /** - * Push registration is bound to a per-wallet owner secret rather than a - * bare walletAddress: this codebase has no signature-based wallet auth (no - * route anywhere verifies private-key ownership), so a cryptographic - * "authenticated wallet" check isn't available to build on here. Instead, - * the first POST for a walletAddress mints a random secret and returns it - * once; every later POST/DELETE/GET for that wallet must present the same - * secret. This stops a third party who only knows or guesses a wallet - * address from enumerating, overwriting, or deleting someone else's - * registration — the concrete attacks this route previously allowed. + * Push registration is bound to a per-wallet owner secret rather than a bare + * walletAddress. The secret prevents a client that only knows another wallet + * address from replacing or deleting its push registration. + * + * This is separate from notification preference sync: preferences are keyed by + * wallet in the durable preference store, while this secret is only used to + * manage a browser's PushSubscription. */ -const ownerSecrets = new Map() +const ownerSecrets = new Map(); function digest(value: string): Buffer { - return createHash("sha256").update(value).digest() + return createHash("sha256").update(value).digest(); } /** Constant-time compare via fixed-length digests, so lengths never leak. */ function secretMatches(walletAddress: string, candidate: string | null | undefined): boolean { - const stored = ownerSecrets.get(walletAddress.toLowerCase()) - if (!stored || !candidate) return false - return timingSafeEqual(digest(stored), digest(candidate)) + const stored = ownerSecrets.get(walletAddress.toLowerCase()); + if (!stored || !candidate) return false; + return timingSafeEqual(digest(stored), digest(candidate)); } -import { withValidation } from "@/lib/api/withValidation" -import { withErrorHandling } from "@/lib/api/withErrorHandling" -import { pushTokenRegisterBodySchema, pushTokenDeleteBodySchema } from "@hunty/types/api-schemas" function mintSecret(): string { - return randomBytes(32).toString("hex") + return randomBytes(32).toString("hex"); } interface PushTokenBody { - subscription?: PushSubscriptionJSON - walletAddress?: string - ownerSecret?: string - preferences?: Record + subscription?: PushSubscriptionJSON; + walletAddress?: string; + ownerSecret?: string; + preferences?: Record; } export const POST = withErrorHandling(async (request: NextRequest) => { - let body: PushTokenBody + let body: PushTokenBody; try { - body = await request.json() + body = await request.json(); } catch { - throw new ValidationError("Invalid request body") + throw new ValidationError("Invalid request body"); } - const { subscription, walletAddress, ownerSecret, preferences } = body + const { subscription, walletAddress, ownerSecret, preferences } = body; if (!walletAddress || typeof walletAddress !== "string") { - throw new ValidationError("Wallet address is required", { field: "walletAddress" }) + throw new ValidationError("Wallet address is required", { field: "walletAddress" }); } - if (!subscription || typeof subscription !== "object" || typeof subscription.endpoint !== "string") { - throw new ValidationError("A valid push subscription is required", { field: "subscription" }) + if ( + !subscription || + typeof subscription !== "object" || + typeof subscription.endpoint !== "string" + ) { + throw new ValidationError("A valid push subscription is required", { field: "subscription" }); } - const key = walletAddress.toLowerCase() - const isFirstRegistration = !ownerSecrets.has(key) + const key = walletAddress.toLowerCase(); + const isFirstRegistration = !ownerSecrets.has(key); if (!isFirstRegistration && !secretMatches(walletAddress, ownerSecret)) { - throw new ForbiddenError("A valid ownerSecret is required to update this wallet's push registration") + throw new ForbiddenError( + "A valid ownerSecret is required to update this wallet's push registration" + ); } - upsertSubscription(subscription, walletAddress, preferences) + upsertSubscription(subscription, walletAddress, preferences); if (isFirstRegistration) { - const secret = mintSecret() - ownerSecrets.set(key, secret) - // Returned exactly once, on first registration — the caller must persist - // this to manage (re-sync preferences on, or unregister) this wallet's - // push subscription later. - return NextResponse.json({ success: true, ownerSecret: secret }) + const secret = mintSecret(); + ownerSecrets.set(key, secret); + // Returned once. The client persists it and sends it on later updates or + // when it unsubscribes. + return NextResponse.json({ success: true, ownerSecret: secret }); } -export const POST = withValidation( - { body: pushTokenRegisterBodySchema }, - async (_request: NextRequest, _context, { body }) => { - const { token, walletAddress } = body - const existingIndex = tokensStore.findIndex( - (t) => t.token === token || t.walletAddress === walletAddress - ) + return NextResponse.json({ success: true }); +}); export const DELETE = withErrorHandling(async (request: NextRequest) => { - let body: { walletAddress?: string; ownerSecret?: string } + let body: { walletAddress?: string; ownerSecret?: string }; try { - body = await request.json() + body = await request.json(); } catch { - throw new ValidationError("Invalid request body") + throw new ValidationError("Invalid request body"); } - const { walletAddress, ownerSecret } = body + const { walletAddress, ownerSecret } = body; if (!walletAddress || typeof walletAddress !== "string") { - throw new ValidationError("Wallet address is required", { field: "walletAddress" }) + throw new ValidationError("Wallet address is required", { field: "walletAddress" }); } - const key = walletAddress.toLowerCase() + const key = walletAddress.toLowerCase(); if (!ownerSecrets.has(key)) { - // Nothing registered for this wallet: idempotent no-op. Don't - // distinguish this from a wrong-secret rejection below, or the - // response would leak whether a wallet has ever registered. - return NextResponse.json({ success: true }) + // Idempotent no-op. Do not reveal whether this wallet has registered. + return NextResponse.json({ success: true }); } if (!secretMatches(walletAddress, ownerSecret)) { - throw new ForbiddenError("A valid ownerSecret is required to remove this wallet's push registration") + throw new ForbiddenError( + "A valid ownerSecret is required to remove this wallet's push registration" + ); } - removeSubscriptionsForWallet(walletAddress) - ownerSecrets.delete(key) + removeSubscriptionsForWallet(walletAddress); + ownerSecrets.delete(key); - return NextResponse.json({ success: true }) -}) - if (existingIndex !== -1) { - tokensStore[existingIndex] = { token, walletAddress, registeredAt: Date.now() } - } else { - tokensStore.push({ token, walletAddress, registeredAt: Date.now() }) - } - - return NextResponse.json({ success: true }) - } -) - -export const DELETE = withValidation( - { body: pushTokenDeleteBodySchema }, - async (_request: NextRequest, _context, { body }) => { - if (body.token) { - const idx = tokensStore.findIndex((t) => t.token === body.token) - if (idx !== -1) tokensStore.splice(idx, 1) - } else if (body.walletAddress) { - for (let i = tokensStore.length - 1; i >= 0; i--) { - if (tokensStore[i].walletAddress === body.walletAddress) { - tokensStore.splice(i, 1) - } - } - } - - return NextResponse.json({ success: true }) - } -) + return NextResponse.json({ success: true }); +}); export const GET = withErrorHandling(async (request: Request) => { - const { searchParams } = new URL(request.url) - const walletAddress = searchParams.get("walletAddress") - const ownerSecret = searchParams.get("ownerSecret") + const { searchParams } = new URL(request.url); + const walletAddress = searchParams.get("walletAddress"); + const ownerSecret = searchParams.get("ownerSecret"); if (!walletAddress || !secretMatches(walletAddress, ownerSecret)) { - // Identical response whether the wallet has never registered or the - // secret is wrong, so this can't be used to probe which wallets are - // registered for push. - return NextResponse.json({ registered: false }) + // Identical response whether the wallet never registered or the secret is + // wrong, so this cannot be used to probe which wallets use push. + return NextResponse.json({ registered: false }); } - const subscriptions = getSubscriptionsForWallet(walletAddress) + const subscriptions = getSubscriptionsForWallet(walletAddress); return NextResponse.json({ registered: subscriptions.length > 0, registeredAt: subscriptions[0]?.registeredAt, - }) -}) + }); +}); diff --git a/apps/web/app/api/push/send/route.ts b/apps/web/app/api/push/send/route.ts index 50bd34501..0d58521d1 100644 --- a/apps/web/app/api/push/send/route.ts +++ b/apps/web/app/api/push/send/route.ts @@ -1,127 +1,79 @@ -import { NextRequest, NextResponse } from "next/server" -import { logger } from "@/lib/logger" -import { rateLimit, getIP, rateLimitResponse } from "@/lib/rate-limit" -import { notifyWallet, notifyWallets } from "@/lib/notifications/pushService" -import type { PushEventType } from "@/lib/notifications/types" -import { AuthError, InternalError, ValidationError } from "@/lib/api/errors" -import { withErrorHandling } from "@/lib/api/withErrorHandling" -import { withValidation } from "@/lib/api/withValidation" -import { pushSendBodySchema } from "@hunty/types/api-schemas" +import { NextRequest, NextResponse } from "next/server"; + +import { AuthError, InternalError, ValidationError } from "@/lib/api/errors"; +import { withErrorHandling } from "@/lib/api/withErrorHandling"; +import { logger } from "@/lib/logger"; +import { getIP, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { notifyWallet, notifyWallets } from "@/lib/notifications/pushService"; +import type { PushEventType } from "@/lib/notifications/types"; /** - * POST /api/push/send - * - * Internal service-to-service endpoint for triggering Web Push notifications - * on hunt events. Callers must present either PUSH_API_SECRET (the dedicated - * push service credential) or ADMIN_API_SECRET (the shared admin credential - * used elsewhere in this app) as a bearer token. - * - * Unlike @/lib/api/adminAuth's assertAdminAuth, this check is unconditional: - * there is no "unprotected in dev when unset" fallback. If neither secret is - * configured, every request is rejected — a push-fan-out endpoint has no - * legitimate reason to ever run open. - * - * This must never be called directly from browser/client code: a secret - * shipped in client JS isn't a secret. Trigger sends from server-side code - * that already holds one of these credentials. - * - * Body: - * { - * type: PushEventType, - * walletAddresses: string[], // recipients - * context: Record // event-specific data (huntName, huntId, etc.) - * } + * Internal service-to-service endpoint for triggering Web Push notifications. + * Browser code must never call this endpoint because the credential is secret. */ function assertServiceOrAdminAuth(request: Request): void { - const authHeader = request.headers.get("Authorization") ?? "" - const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null - - const pushSecret = process.env.PUSH_API_SECRET - const adminSecret = process.env.ADMIN_API_SECRET + const authHeader = request.headers.get("Authorization") ?? ""; + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; + const pushSecret = process.env.PUSH_API_SECRET; + const adminSecret = process.env.ADMIN_API_SECRET; - const matchesPush = Boolean(token && pushSecret && token === pushSecret) - const matchesAdmin = Boolean(token && adminSecret && token === adminSecret) + const matchesPush = Boolean(token && pushSecret && token === pushSecret); + const matchesAdmin = Boolean(token && adminSecret && token === adminSecret); if (!matchesPush && !matchesAdmin) { - throw new AuthError("A valid service or admin credential is required") + throw new AuthError("A valid service or admin credential is required"); } } +const validTypes: PushEventType[] = [ + "hunt_start", + "hunt_cancelled", + "leaderboard_overtake", + "player_registered", + "first_completion", +]; + export const POST = withErrorHandling(async (request: NextRequest) => { - const ip = getIP(request) - const { success, reset } = rateLimit(ip, { limit: 50, windowMs: 60 * 1000 }) - if (!success) return rateLimitResponse(reset) + const ip = getIP(request); + const { success, reset } = await rateLimit(ip, { limit: 50, windowMs: 60 * 1000 }); + if (!success) return rateLimitResponse(reset); - assertServiceOrAdminAuth(request) + assertServiceOrAdminAuth(request); - let body: { type?: string; walletAddresses?: string[]; context?: Record } + let body: { + type?: string; + walletAddresses?: string[]; + context?: Record; + }; try { - body = await request.json() + body = await request.json(); } catch { - throw new ValidationError("Invalid request body") + throw new ValidationError("Invalid request body"); } - const { type, walletAddresses, context = {} } = body - - if (!type || typeof type !== "string") { - throw new ValidationError("type is required", { field: "type" }) + const { type, walletAddresses, context = {} } = body; + if (!type || !validTypes.includes(type as PushEventType)) { + throw new ValidationError(`Invalid type. Must be one of: ${validTypes.join(", ")}`, { + field: "type", + }); } - if (!Array.isArray(walletAddresses) || walletAddresses.length === 0) { - throw new ValidationError("walletAddresses must be a non-empty array", { field: "walletAddresses" }) - } -export const POST = withValidation( - { body: pushSendBodySchema }, - async (request: NextRequest, _context, { body }) => { - const ip = getIP(request) - const { success, reset } = await rateLimit(ip, { limit: 50, windowMs: 60 * 1000 }) - if (!success) return rateLimitResponse(reset) - - const secret = process.env.PUSH_API_SECRET - if (secret) { - const authHeader = request.headers.get("Authorization") - if (!authHeader || authHeader !== `Bearer ${secret}`) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - } - - try { - if (body.walletAddresses.length === 1) { - await notifyWallet(body.walletAddresses[0], body.type as PushEventType, body.context) - } else { - await notifyWallets(body.walletAddresses, body.type as PushEventType, body.context) - } - } catch (error) { - logger.error("[push/send] Failed to send push notification:", error) - return NextResponse.json( - { error: "Failed to send push notification" }, - { status: 500 } - ) - } - - if (!validTypes.includes(type as PushEventType)) { - throw new ValidationError(`Invalid type. Must be one of: ${validTypes.join(", ")}`, { field: "type" }) + throw new ValidationError("walletAddresses must be a non-empty array", { + field: "walletAddresses", + }); } try { if (walletAddresses.length === 1) { - await notifyWallet(walletAddresses[0], type as PushEventType, context) + await notifyWallet(walletAddresses[0], type as PushEventType, context); } else { - await notifyWallets(walletAddresses, type as PushEventType, context) + await notifyWallets(walletAddresses, type as PushEventType, context); } } catch (error) { - logger.error("[push/send] Failed to send push notification:", error) - throw new InternalError("Failed to send push notification") + logger.error("[push/send] Failed to send push notification:", error); + throw new InternalError("Failed to send push notification"); } - logger.info(`[push/send] Sent "${type}" to ${walletAddresses.length} wallet(s)`) - - return NextResponse.json({ success: true, sent: walletAddresses.length }) -}) - logger.info( - `[push/send] Sent "${body.type}" to ${body.walletAddresses.length} wallet(s)` - ) - - return NextResponse.json({ success: true, sent: body.walletAddresses.length }) - } -) + logger.info(`[push/send] Sent "${type}" to ${walletAddresses.length} wallet(s)`); + return NextResponse.json({ success: true, sent: walletAddresses.length }); +}); diff --git a/apps/web/app/api/v1/notifications/preferences/__tests__/route.test.ts b/apps/web/app/api/v1/notifications/preferences/__tests__/route.test.ts new file mode 100644 index 000000000..107c5fd87 --- /dev/null +++ b/apps/web/app/api/v1/notifications/preferences/__tests__/route.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const walletAddress = "GPLAYER"; + +function request(method: string, body?: unknown): Request { + return new Request("http://localhost/api/v1/notifications/preferences", { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); +} + +function getRequest(wallet = walletAddress): Request { + return new Request( + `http://localhost/api/v1/notifications/preferences?walletAddress=${encodeURIComponent(wallet)}` + ); +} + +describe("/api/v1/notifications/preferences", () => { + beforeEach(() => { + delete process.env.DATABASE_URL; + vi.resetModules(); + }); + + it("returns defaults for a wallet with no saved preferences", async () => { + const { GET } = await import("../route"); + const response = await GET(getRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.preferences).toMatchObject({ + enabled: true, + huntEvents: true, + rewards: true, + social: true, + achievements: true, + }); + }); + + it("persists independent category changes for the wallet", async () => { + const { GET, PUT } = await import("../route"); + const response = await PUT( + request("PUT", { + walletAddress, + preferences: { huntEvents: false, social: false }, + }) + ); + const saved = await response.json(); + + expect(response.status).toBe(200); + expect(saved.preferences.huntEvents).toBe(false); + expect(saved.preferences.social).toBe(false); + expect(saved.preferences.rewards).toBe(true); + + const readBack = await GET(getRequest()); + const readBody = await readBack.json(); + expect(readBody.preferences.huntEvents).toBe(false); + expect(readBody.preferences.social).toBe(false); + expect(readBody.preferences.rewards).toBe(true); + }); + + it("stores the global mute without changing category choices", async () => { + const { GET, PUT } = await import("../route"); + await PUT( + request("PUT", { + walletAddress, + preferences: { rewards: false }, + }) + ); + await PUT( + request("PUT", { + walletAddress, + preferences: { enabled: false }, + }) + ); + + const response = await GET(getRequest()); + const body = await response.json(); + expect(body.preferences.enabled).toBe(false); + expect(body.preferences.rewards).toBe(false); + }); + + it("does not share one wallet's preferences with another wallet", async () => { + const { GET, PUT } = await import("../route"); + await PUT( + request("PUT", { + walletAddress, + preferences: { social: false }, + }) + ); + + const response = await GET(getRequest("GOTHER")); + const body = await response.json(); + expect(body.preferences.social).toBe(true); + }); + + it("rejects writes without a wallet and preference document", async () => { + const { PUT } = await import("../route"); + const response = await PUT(request("PUT", { preferences: { social: false } })); + expect(response.status).toBe(400); + }); +}); diff --git a/apps/web/app/api/v1/notifications/preferences/route.ts b/apps/web/app/api/v1/notifications/preferences/route.ts new file mode 100644 index 000000000..284a06c71 --- /dev/null +++ b/apps/web/app/api/v1/notifications/preferences/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; + +import { + getStoredNotificationPreferences, + saveNotificationPreferences, +} from "@/lib/notifications/notificationPreferencesStore"; +import { withValidation } from "@/lib/api/withValidation"; +import { + notificationPreferencesBodySchema, + notificationPreferencesQuerySchema, +} from "@hunty/types/api-schemas"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/v1/notifications/preferences?walletAddress=... + * + * Returns the canonical preference document for a wallet. A new wallet gets + * the default document without creating a database row until the first write. + */ +export const GET = withValidation( + { query: notificationPreferencesQuerySchema }, + async (_request, _context, { query }) => { + const preferences = await getStoredNotificationPreferences(query.walletAddress); + return NextResponse.json({ preferences }); + } +); + +async function writePreferences( + _request: Request, + _context: unknown, + { body }: { body: { walletAddress: string; preferences: Record } } +): Promise { + const current = await getStoredNotificationPreferences(body.walletAddress); + const preferences = await saveNotificationPreferences(body.walletAddress, { + ...current, + ...body.preferences, + }); + + return NextResponse.json({ preferences }); +} + +const validatedWrite = withValidation( + { body: notificationPreferencesBodySchema }, + writePreferences +); + +/** PUT is the primary client operation; PATCH and POST keep the endpoint easy + * to consume from mobile clients and older API integrations. */ +export const PUT = validatedWrite; +export const PATCH = validatedWrite; +export const POST = validatedWrite; diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 1c5a67512..280e1ce5a 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -2,15 +2,28 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "next-themes"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { FeatureFlagProvider } from "@/components/FeatureFlagProvider"; import { WebVitalsReporter } from "@/components/WebVitalsReporter"; import { NetworkMismatchWarning } from "@/components/NetworkMismatchWarning"; import { SessionProvider } from "@/lib/context/SessionContext"; import { WalletProvider, useWallet } from "@/lib/context/WalletContext"; +import { fetchNotificationPreferences } from "@/lib/notifications/notificationPreferences"; import { queryCachePolicy } from "@/lib/queryKeys"; +function NotificationPreferencesSync() { + const { connected, publicKey } = useWallet(); + + useEffect(() => { + if (connected && publicKey) { + void fetchNotificationPreferences(publicKey); + } + }, [connected, publicKey]); + + return null; +} + function NetworkWarningWrapper() { const { walletProvider, connected } = useWallet(); return ; @@ -35,6 +48,7 @@ export default function Providers({ children }: { children: React.ReactNode }) { return ( + diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx new file mode 100644 index 000000000..74dec6bb4 --- /dev/null +++ b/apps/web/app/settings/page.tsx @@ -0,0 +1,59 @@ +import { Bell, Network, Settings } from "lucide-react"; + +import { Header } from "@/components/Header"; +import { NetworkSwitcher } from "@/components/NetworkSwitcher"; +import { NotificationSettings } from "@/components/NotificationSettings"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function SettingsPage() { + return ( +
+
+
+
+
+ +

+ Settings +

+
+

+ Manage your app preferences and network configuration +

+
+ +
+ + +
+ + Network Settings +
+ + Choose which Stellar network to use for transactions and smart contracts + +
+ + + +
+ + + +
+ + Notification Preferences +
+ + Choose notification categories independently or mute everything at once + +
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/components/FavoriteNotifications.tsx b/apps/web/components/FavoriteNotifications.tsx index 593b1fa7a..218c9032e 100644 --- a/apps/web/components/FavoriteNotifications.tsx +++ b/apps/web/components/FavoriteNotifications.tsx @@ -6,6 +6,7 @@ import { useFavorites } from "@/hooks/useFavorites" import { getAllHunts } from "@/lib/huntStore" import { WalletContext } from "@/lib/context/WalletContext" import { logger } from "@/lib/logger" +import { shouldNotifyForCategory } from "@/lib/notifications/notificationPreferences" export function FavoriteNotifications() { const { favorites, isLoaded } = useFavorites() @@ -19,6 +20,9 @@ export function FavoriteNotifications() { const checkNotifications = () => { try { + // Favorite start alerts must respect the global mute and hunt-events category. + if (!shouldNotifyForCategory("huntEvents")) return + const storedNotified = localStorage.getItem(storageKey) const storedNotifiedSoon = localStorage.getItem(`${storageKey}_soon`) diff --git a/apps/web/components/NotificationSettings.tsx b/apps/web/components/NotificationSettings.tsx index a93a82020..048273cad 100644 --- a/apps/web/components/NotificationSettings.tsx +++ b/apps/web/components/NotificationSettings.tsx @@ -1,143 +1,280 @@ -"use client" +"use client"; -import { Bell, BellOff, Calendar,ChevronDown, ChevronUp, Users } from "lucide-react" -import React, { useEffect,useState } from "react" +import { + Bell, + BellOff, + Calendar, + ChevronDown, + ChevronUp, + Flag, + Gift, + Trophy, + Users, +} from "lucide-react"; +import React, { useContext, useEffect, useState } from "react"; -import { getNotificationPreferences, setNotificationPreferences } from "@/lib/notifications/notificationPreferences" -import type { NotificationPreferences } from "@/lib/notifications/types" -import { PushNotificationToggle } from "@/components/PushNotificationToggle" -import { syncPreferencesToServer } from "@/lib/notifications/webPush" +import { PushNotificationToggle } from "@/components/PushNotificationToggle"; +import { WalletContext } from "@/lib/context/WalletContext"; +import { + fetchNotificationPreferences, + getNotificationPreferences, + setNotificationPreferences, + syncNotificationPreferences, +} from "@/lib/notifications/notificationPreferences"; +import type { NotificationPreferences } from "@/lib/notifications/types"; +import { syncPreferencesToServer } from "@/lib/notifications/webPush"; interface NotificationSettingsProps { - onClose?: () => void - /** Wallet address used for push subscription — pass from the connected wallet context */ - walletAddress?: string | null + onClose?: () => void; + /** Optional override, useful when this component is embedded elsewhere. */ + walletAddress?: string | null; } -export function NotificationSettings({ onClose, walletAddress = null }: NotificationSettingsProps) { - const [prefs, setPrefs] = useState(getNotificationPreferences()) +type BooleanPreferenceKey = { + [Key in keyof NotificationPreferences]: NotificationPreferences[Key] extends boolean + ? Key + : never; +}[keyof NotificationPreferences]; +export function NotificationSettings({ walletAddress = null }: NotificationSettingsProps) { + const wallet = useContext(WalletContext); + const connectedWallet = walletAddress ?? wallet?.publicKey ?? null; + const [prefs, setPrefs] = useState(getNotificationPreferences()); + const [hydrated, setHydrated] = useState(false); + + // The local copy renders immediately, then the wallet-scoped server copy wins + // once it arrives. This gives offline users a useful settings screen while + // still making a connected wallet's settings portable across devices. useEffect(() => { - setNotificationPreferences(prefs) - // Re-sync push preferences to server whenever they change - if (walletAddress && prefs.pushEnabled) { - syncPreferencesToServer(walletAddress, { - huntStart: prefs.pushHuntStart, - overtake: prefs.pushOvertake, - huntCancelled: prefs.pushHuntCancelled, - playerRegistered: prefs.pushPlayerRegistered, - firstCompletion: prefs.pushFirstCompletion, - }).catch(() => { - // Non-fatal — local prefs already saved - }) + let cancelled = false; + + if (!connectedWallet) { + setHydrated(true); + return () => { + cancelled = true; + }; } - }, [prefs, walletAddress]) - const toggle = (key: keyof NotificationPreferences) => { - setPrefs((prev) => ({ ...prev, [key]: !prev[key] as boolean })) - } + setHydrated(false); + void fetchNotificationPreferences(connectedWallet).then((serverPrefs) => { + if (cancelled) return; + if (serverPrefs) setPrefs(serverPrefs); + setHydrated(true); + }); + + return () => { + cancelled = true; + }; + }, [connectedWallet]); + + // Persist locally and debounce the network write so rapid switch changes do + // not race each other. The API merges the complete normalized document by + // wallet, so the latest document is what every device reads. + useEffect(() => { + if (!hydrated) return; + + setNotificationPreferences(prefs); + if (!connectedWallet) return; + + const timeout = window.setTimeout(() => { + // The browser-push toggle owns its own state, so read the latest local + // document here rather than relying only on this component's render. + const latest = getNotificationPreferences(); + void syncNotificationPreferences(connectedWallet, latest); + + // Web Push has its own subscription record because delivery happens + // without a page open. The helper no-ops if push is not subscribed. + void syncPreferencesToServer(connectedWallet, { + enabled: latest.enabled, + huntEvents: latest.huntEvents, + rewards: latest.rewards, + social: latest.social, + achievements: latest.achievements, + huntStart: latest.pushHuntStart, + overtake: latest.pushOvertake, + huntCancelled: latest.pushHuntCancelled, + playerRegistered: latest.pushPlayerRegistered, + firstCompletion: latest.pushFirstCompletion, + }); + }, 250); + + return () => window.clearTimeout(timeout); + }, [connectedWallet, hydrated, prefs]); + + const toggle = (key: BooleanPreferenceKey) => { + setPrefs((previous) => { + // PushNotificationToggle also persists its flag locally. Preserve that + // child-owned value when another switch causes this component to render. + const latestPushEnabled = getNotificationPreferences().pushEnabled; + return { + ...previous, + pushEnabled: latestPushEnabled, + [key]: !previous[key], + }; + }); + }; const setThreshold = (value: number) => { - setPrefs((prev) => ({ ...prev, threshold: Math.max(1, value) })) - } + setPrefs((previous) => ({ ...previous, threshold: Math.max(1, value) })); + }; return (
{prefs.enabled ? ( - + ) : ( - + )} - - Push Notifications - +
+ + Notifications + +

+ {prefs.enabled + ? "Choose what Hunty can notify you about" + : "All notifications are muted"} +

+
- + toggle("enabled")} + label="Mute all notifications" + />
- {prefs.enabled && ( - <> -
- } - label="Rank improvement" - description="When you move up in rank" - checked={prefs.rankImproved} - onChange={() => toggle("rankImproved")} - /> - } - label="Rank drop" - description="When you move down in rank" - checked={prefs.rankDropped} - onChange={() => toggle("rankDropped")} - /> - } - label="Overtaken" - description="When another player overtakes you" - checked={prefs.overtaken} - onChange={() => toggle("overtaken")} - /> - } - label="Weekly digest" - description="Weekly rank summary" - checked={prefs.weeklyDigest} - onChange={() => toggle("weeklyDigest")} - /> -
+
+

+ Notification categories +

+ } + label="Hunt events" + description="Starts, reminders and cancellations" + checked={prefs.enabled && prefs.huntEvents} + onChange={() => toggle("huntEvents")} + /> + } + label="Rewards & progress" + description="Rewards and correct answers" + checked={prefs.enabled && prefs.rewards} + onChange={() => toggle("rewards")} + /> + } + label="Social & competition" + description="Leaderboard activity and rank changes" + checked={prefs.enabled && prefs.social} + onChange={() => toggle("social")} + /> + } + label="Achievements" + description="When you unlock an achievement" + checked={prefs.enabled && prefs.achievements} + onChange={() => toggle("achievements")} + /> +
-
- -

- Only notify when rank changes by at least this many positions -

-
- {[1, 2, 3, 5, 10].map((v) => ( - - ))} -
-
+
+

+ Rank detail +

+ } + label="Rank improvement" + description="When you move up in rank" + checked={prefs.enabled && prefs.social && prefs.rankImproved} + onChange={() => toggle("rankImproved")} + /> + } + label="Rank drop" + description="When you move down in rank" + checked={prefs.enabled && prefs.social && prefs.rankDropped} + onChange={() => toggle("rankDropped")} + /> + } + label="Overtaken" + description="When another player overtakes you" + checked={prefs.enabled && prefs.social && prefs.overtaken} + onChange={() => toggle("overtaken")} + /> + } + label="Weekly digest" + description="Weekly rank summary" + checked={prefs.enabled && prefs.social && prefs.weeklyDigest} + onChange={() => toggle("weeklyDigest")} + /> +
- {/* Web Push opt-in */} -
- -
- - )} +
+ +

+ Only notify when rank changes by at least this many positions +

+
+ {[1, 2, 3, 5, 10].map((value) => ( + + ))} +
+
+ +
+ +
- ) + ); +} + +function Switch({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: () => void; + label: string; +}) { + return ( + + ); } function ToggleRow({ @@ -147,41 +284,26 @@ function ToggleRow({ checked, onChange, }: { - icon: React.ReactNode - label: string - description: string - checked: boolean - onChange: () => void + icon: React.ReactNode; + label: string; + description: string; + checked: boolean; + onChange: () => void; }) { return ( -
-
+
+
{icon}
-

{label}

-

{description}

+

{label}

+

{description}

- +
- ) + ); } function cn(...inputs: (string | boolean | undefined | null)[]): string { - return inputs.filter(Boolean).join(" ") + return inputs.filter(Boolean).join(" "); } diff --git a/apps/web/hooks/usePushNotifications.ts b/apps/web/hooks/usePushNotifications.ts index ef5d76392..655ddb624 100644 --- a/apps/web/hooks/usePushNotifications.ts +++ b/apps/web/hooks/usePushNotifications.ts @@ -20,7 +20,11 @@ import { registerServiceWorker, syncSubscriptionToServer, } from "@/lib/notifications/webPush" -import { getNotificationPreferences, setNotificationPreferences } from "@/lib/notifications/notificationPreferences" +import { + getNotificationPreferences, + setNotificationPreferences, + syncNotificationPreferences, +} from "@/lib/notifications/notificationPreferences" import { logger } from "@/lib/logger" export type PushState = @@ -101,9 +105,17 @@ export function usePushNotifications( const prefs = getNotificationPreferences() const updated = { ...prefs, pushEnabled: true } setNotificationPreferences(updated) + // Keep the canonical wallet document in sync as well as the device + // subscription. This lets the server suppress stale subscriptions. + await syncNotificationPreferences(walletAddress, updated) // Re-sync with preferences now that we have the subscription await syncSubscriptionToServer(subscription, walletAddress, { + enabled: updated.enabled, + huntEvents: updated.huntEvents, + rewards: updated.rewards, + social: updated.social, + achievements: updated.achievements, huntStart: updated.pushHuntStart, overtake: updated.pushOvertake, huntCancelled: updated.pushHuntCancelled, @@ -128,9 +140,11 @@ export function usePushNotifications( try { await disablePushNotifications(walletAddress) - // Persist preference + // Persist preference in both local storage and the wallet document. const prefs = getNotificationPreferences() - setNotificationPreferences({ ...prefs, pushEnabled: false }) + const updated = { ...prefs, pushEnabled: false } + setNotificationPreferences(updated) + await syncNotificationPreferences(walletAddress, updated) setState("unsubscribed") } catch (err) { diff --git a/apps/web/lib/db/migrations/010_add_notification_preferences.sql b/apps/web/lib/db/migrations/010_add_notification_preferences.sql new file mode 100644 index 000000000..59ba9fd61 --- /dev/null +++ b/apps/web/lib/db/migrations/010_add_notification_preferences.sql @@ -0,0 +1,14 @@ +-- Migration: durable, wallet-scoped notification preferences. +-- +-- A wallet is the user identity shared by the web and mobile clients. Storing +-- the complete document here means changing a category on one device is +-- visible to every other device the player uses. + +CREATE TABLE IF NOT EXISTS notification_preferences ( + wallet_address TEXT PRIMARY KEY, + preferences JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notification_preferences_updated_at + ON notification_preferences (updated_at); diff --git a/apps/web/lib/notifications/huntScheduleNotifications.ts b/apps/web/lib/notifications/huntScheduleNotifications.ts index 40ccead24..a65b77493 100644 --- a/apps/web/lib/notifications/huntScheduleNotifications.ts +++ b/apps/web/lib/notifications/huntScheduleNotifications.ts @@ -1,40 +1,46 @@ -import { Resend } from "resend" -import { logger } from "@/lib/logger" -import { getNotificationPreferences } from "@/lib/notifications/notificationPreferences" -import type { StoredHunt } from "@/lib/types" +import { Resend } from "resend"; -const RESEND_API_KEY = process.env.RESEND_API_KEY +import { logger } from "@/lib/logger"; +import { getStoredNotificationPreferences } from "@/lib/notifications/notificationPreferencesStore"; +import { getNotificationPreferences } from "@/lib/notifications/notificationPreferences"; +import type { StoredHunt } from "@/lib/types"; + +const RESEND_API_KEY = process.env.RESEND_API_KEY; export type HuntReminderPayload = { - hunt: StoredHunt - recipientEmail: string - startTime: number -} + hunt: StoredHunt; + recipientEmail: string; + /** Wallet identity used to apply the cross-device preferences. */ + recipientWalletAddress?: string; + startTime: number; +}; export async function sendHuntStartReminder(payload: HuntReminderPayload): Promise { - if (!payload.recipientEmail) return false + if (!payload.recipientEmail) return false; - const prefs = getNotificationPreferences() - if (!prefs.enabled) return false + const prefs = payload.recipientWalletAddress + ? await getStoredNotificationPreferences(payload.recipientWalletAddress) + : getNotificationPreferences(); + if (!prefs.enabled || !prefs.huntEvents) return false; if (typeof window === "undefined" && !RESEND_API_KEY) { - logger.warn("RESEND_API_KEY is not configured; skipping reminder email") - return false + logger.warn("RESEND_API_KEY is not configured; skipping reminder email"); + return false; } try { if (typeof window === "undefined") { - const resend = new Resend(RESEND_API_KEY) + const resend = new Resend(RESEND_API_KEY); await resend.emails.send({ from: "Hunty ", to: [payload.recipientEmail], subject: `Your hunt starts soon: ${payload.hunt.title}`, text: `${payload.hunt.title} is scheduled to start at ${new Date(payload.startTime * 1000).toLocaleString()}.`, - }) + }); } - return true + return true; } catch (error) { - logger.error("Failed to send hunt reminder", error) - return false + logger.error("Failed to send hunt reminder", error); + return false; } } diff --git a/apps/web/lib/notifications/notificationPreferences.ts b/apps/web/lib/notifications/notificationPreferences.ts index eb03823a6..8537fc8e5 100644 --- a/apps/web/lib/notifications/notificationPreferences.ts +++ b/apps/web/lib/notifications/notificationPreferences.ts @@ -1,7 +1,11 @@ import { logger } from "@/lib/logger" -import type { NotificationPreferences } from "./types" -import { DEFAULT_NOTIFICATION_PREFERENCES } from "./types" +import { + DEFAULT_NOTIFICATION_PREFERENCES, + normalizeNotificationPreferences, + type NotificationCategory, + type NotificationPreferences, +} from "./types" const PREFS_KEY = "hunty_notification_prefs" @@ -11,7 +15,7 @@ export function getNotificationPreferences(): NotificationPreferences { try { const raw = localStorage.getItem(PREFS_KEY) if (!raw) return { ...DEFAULT_NOTIFICATION_PREFERENCES } - return { ...DEFAULT_NOTIFICATION_PREFERENCES, ...JSON.parse(raw) } + return normalizeNotificationPreferences(JSON.parse(raw) as Partial) } catch (error) { logger.error("Failed to load notification preferences:", error) return { ...DEFAULT_NOTIFICATION_PREFERENCES } @@ -22,18 +26,74 @@ export function setNotificationPreferences(prefs: NotificationPreferences): void if (typeof window === "undefined") return try { - localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) + localStorage.setItem(PREFS_KEY, JSON.stringify(normalizeNotificationPreferences(prefs))) } catch (error) { logger.error("Failed to save notification preferences:", error) } } +/** Fetch the wallet's canonical preferences for cross-device hydration. */ +export async function fetchNotificationPreferences( + walletAddress: string +): Promise { + if (!walletAddress) return null + + try { + const response = await fetch( + `/api/v1/notifications/preferences?walletAddress=${encodeURIComponent(walletAddress)}`, + { headers: { Accept: "application/json" } } + ) + if (!response.ok) return null + + const body = (await response.json()) as { + preferences?: Partial + } + if (!body.preferences) return null + + const preferences = normalizeNotificationPreferences(body.preferences) + setNotificationPreferences(preferences) + return preferences + } catch (error) { + logger.warn("Failed to fetch notification preferences; using local copy", error) + return null + } +} + +/** Persist the preference document for a connected wallet. */ +export async function syncNotificationPreferences( + walletAddress: string, + prefs: NotificationPreferences +): Promise { + if (!walletAddress) return false + + const preferences = normalizeNotificationPreferences(prefs) + setNotificationPreferences(preferences) + + try { + const response = await fetch("/api/v1/notifications/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ walletAddress, preferences }), + }) + return response.ok + } catch (error) { + logger.warn("Failed to sync notification preferences", error) + return false + } +} + +/** Global mute is checked before an individual category is evaluated. */ +export function shouldNotifyForCategory(category: NotificationCategory): boolean { + const prefs = getNotificationPreferences() + return prefs.enabled && prefs[category] +} + export function shouldNotifyForRankChange( type: "rank_improved" | "rank_dropped" | "overtaken", changeMagnitude: number ): boolean { const prefs = getNotificationPreferences() - if (!prefs.enabled) return false + if (!prefs.enabled || !prefs.social) return false if (changeMagnitude < prefs.threshold) return false switch (type) { diff --git a/apps/web/lib/notifications/notificationPreferencesStore.ts b/apps/web/lib/notifications/notificationPreferencesStore.ts new file mode 100644 index 000000000..b92225fb7 --- /dev/null +++ b/apps/web/lib/notifications/notificationPreferencesStore.ts @@ -0,0 +1,99 @@ +/** + * Server-side persistence for notification preferences. + * + * Preferences are keyed by the connected Stellar wallet, not by a browser + * device. This is what makes one change visible on a phone, another browser, + * and the web app. + */ + +import { getDb } from "@/lib/db"; +import { logger } from "@/lib/logger"; + +import { + DEFAULT_NOTIFICATION_PREFERENCES, + normalizeNotificationPreferences, + type NotificationPreferences, + type NotificationPreferencesPatch, +} from "./types"; + +const memoryStore = new Map(); + +function walletKey(walletAddress: string): string { + return walletAddress.trim().toLowerCase(); +} + +function clonePreferences(prefs: NotificationPreferences): NotificationPreferences { + return { ...prefs }; +} + +/** + * Read preferences from PostgreSQL when configured. The in-memory fallback is + * intentional for local development and unit tests; production deployments + * should configure DATABASE_URL and run migration 010. + */ +export async function getStoredNotificationPreferences( + walletAddress: string +): Promise { + const normalizedWallet = walletKey(walletAddress); + const fallback = memoryStore.get(normalizedWallet) ?? DEFAULT_NOTIFICATION_PREFERENCES; + + if (!process.env.DATABASE_URL) return clonePreferences(fallback); + + try { + const sql = getDb(); + const [row] = await sql` + SELECT preferences FROM notification_preferences + WHERE wallet_address = ${normalizedWallet} + `; + + if (!row || row.preferences == null) return clonePreferences(fallback); + + const stored = + typeof row.preferences === "string" + ? (JSON.parse(row.preferences) as NotificationPreferencesPatch) + : (row.preferences as NotificationPreferencesPatch); + const preferences = normalizeNotificationPreferences(stored); + memoryStore.set(normalizedWallet, preferences); + return clonePreferences(preferences); + } catch (error) { + // Do not make the settings screen unusable during a transient database + // outage. The caller receives the last known local process value and the + // next successful request will read the canonical database copy. + logger.warn("Failed to read notification preferences from database", error); + return clonePreferences(fallback); + } +} + +/** Persist the complete preference document for a wallet. */ +export async function saveNotificationPreferences( + walletAddress: string, + value: NotificationPreferencesPatch +): Promise { + const normalizedWallet = walletKey(walletAddress); + const preferences = normalizeNotificationPreferences(value); + memoryStore.set(normalizedWallet, preferences); + + if (!process.env.DATABASE_URL) return clonePreferences(preferences); + + try { + const sql = getDb(); + await sql` + INSERT INTO notification_preferences (wallet_address, preferences, updated_at) + VALUES (${normalizedWallet}, ${JSON.stringify(preferences)}, NOW()) + ON CONFLICT (wallet_address) DO UPDATE SET + preferences = EXCLUDED.preferences, + updated_at = NOW() + `; + } catch (error) { + logger.warn("Failed to persist notification preferences to database", error); + // Keep the in-memory copy so a temporary outage does not discard the + // user's change. The API still returns the normalized value. + } + + return clonePreferences(preferences); +} + +/** Reset process-local state in unit tests or during a development reset. */ +export function clearNotificationPreferencesStore(): void { + memoryStore.clear(); +} diff --git a/apps/web/lib/notifications/notificationService.ts b/apps/web/lib/notifications/notificationService.ts index 355e86d79..daa93e40f 100644 --- a/apps/web/lib/notifications/notificationService.ts +++ b/apps/web/lib/notifications/notificationService.ts @@ -1,14 +1,17 @@ import { toast } from "sonner"; import type { LeaderboardRankNotification } from "./types"; -import { shouldNotifyForRankChange } from "./notificationPreferences"; +import { getNotificationPreferences, shouldNotifyForRankChange } from "./notificationPreferences"; import { saveNotifications } from "./rankTracker"; -export function handleRankNotifications( - notifications: LeaderboardRankNotification[] -): void { +export function handleRankNotifications(notifications: LeaderboardRankNotification[]): void { if (notifications.length === 0) return; + const preferences = getNotificationPreferences(); + // Do not add events to the in-app notification center while the player has + // muted the social category (or all notifications). + if (!preferences.enabled || !preferences.social) return; + const filtered = notifications.filter((n) => { const changeMagnitude = Math.abs(n.previousRank - n.currentRank); return shouldNotifyForRankChange(n.type, changeMagnitude); @@ -57,4 +60,3 @@ function showRankToast(notification: LeaderboardRankNotification): void { break; } } - \ No newline at end of file diff --git a/apps/web/lib/notifications/pushService.ts b/apps/web/lib/notifications/pushService.ts index 76b550e14..2c429218a 100644 --- a/apps/web/lib/notifications/pushService.ts +++ b/apps/web/lib/notifications/pushService.ts @@ -9,6 +9,7 @@ import webpush, { type PushSubscription as WebPushSubscription } from "web-push" import { logger } from "@/lib/logger" +import { getStoredNotificationPreferences } from "./notificationPreferencesStore" import type { PushEventType, PushPayload, WebPushSubscriptionRecord } from "./types" import { PUSH_EVENT_PREFERENCE_KEY } from "./types" import { @@ -178,16 +179,40 @@ async function sendToRecords( payload: PushPayload, eventType?: PushEventType ): Promise { - // Filter by per-recipient preferences when we know the event type - const eligible = eventType - ? records.filter((r) => { - const prefs = r.preferences - if (!prefs) return true // no stored prefs → allow (default opt-in) - const key = PUSH_EVENT_PREFERENCE_KEY[eventType] - const flag = prefs[key] - return flag !== false // undefined → allow, false → skip - }) - : records + // Use the wallet-scoped document as the source of truth. The subscription + // record still carries device-level Web Push flags, but using only that + // record would leave another browser subscribed with stale category values. + const recordsWithPreferences = await Promise.all( + records.map(async (record) => ({ + record, + preferences: await getStoredNotificationPreferences(record.walletAddress), + })) + ) + + const eligible = recordsWithPreferences + .filter(({ record, preferences }) => { + const devicePreferences = record.preferences + // The global mute must win over every individual category switch. Check + // both sources so a preference changed on another device takes effect + // before the next push is sent. + if (preferences.enabled === false || devicePreferences?.enabled === false) return false + if (!eventType) return true + + const category = + eventType === "hunt_start" || eventType === "hunt_cancelled" + ? "huntEvents" + : eventType === "first_completion" + ? "achievements" + : "social" + if (preferences[category] === false || devicePreferences?.[category] === false) { + return false + } + + const key = PUSH_EVENT_PREFERENCE_KEY[eventType] + const flag = devicePreferences?.[key] + return flag !== false // undefined → allow, false → skip + }) + .map(({ record }) => record) if (eligible.length === 0) return diff --git a/apps/web/lib/notifications/types.ts b/apps/web/lib/notifications/types.ts index 53edcbdc5..d45eca076 100644 --- a/apps/web/lib/notifications/types.ts +++ b/apps/web/lib/notifications/types.ts @@ -22,6 +22,12 @@ export interface WebPushSubscriptionRecord { * When absent the default is to allow delivery (opt-in assumed). */ preferences?: { + /** Global notification mute, independent from browser permission. */ + enabled?: boolean + huntEvents?: boolean + rewards?: boolean + social?: boolean + achievements?: boolean huntStart?: boolean overtake?: boolean huntCancelled?: boolean @@ -84,8 +90,19 @@ export interface HuntRankSnapshot { entries: RankSnapshot[] } +export type NotificationCategory = "huntEvents" | "rewards" | "social" | "achievements" + export interface NotificationPreferences { + /** Global mute — false suppresses every notification channel. */ enabled: boolean + /** Independent hunt lifecycle category. */ + huntEvents: boolean + /** Independent rewards/progress category. */ + rewards: boolean + /** Independent social/competition category. */ + social: boolean + /** Independent achievement category. */ + achievements: boolean rankImproved: boolean rankDropped: boolean overtaken: boolean @@ -107,8 +124,14 @@ export interface NotificationPreferences { pushFirstCompletion: boolean } +export type NotificationPreferencesPatch = Partial + export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { enabled: true, + huntEvents: true, + rewards: true, + social: true, + achievements: true, rankImproved: true, rankDropped: true, overtaken: true, @@ -121,3 +144,39 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { pushPlayerRegistered: true, pushFirstCompletion: true, } + +const BOOLEAN_PREFERENCE_KEYS = [ + "enabled", + "huntEvents", + "rewards", + "social", + "achievements", + "rankImproved", + "rankDropped", + "overtaken", + "weeklyDigest", + "pushEnabled", + "pushHuntStart", + "pushOvertake", + "pushHuntCancelled", + "pushPlayerRegistered", + "pushFirstCompletion", +] as const + +/** Merge untrusted or legacy data with the current defaults. */ +export function normalizeNotificationPreferences( + value: NotificationPreferencesPatch | null | undefined +): NotificationPreferences { + const input = value ?? {} + const result = { ...DEFAULT_NOTIFICATION_PREFERENCES } + + for (const key of BOOLEAN_PREFERENCE_KEYS) { + if (typeof input[key] === "boolean") result[key] = input[key] as boolean + } + + if (typeof input.threshold === "number" && Number.isFinite(input.threshold)) { + result.threshold = Math.max(1, Math.floor(input.threshold)) + } + + return result +} diff --git a/apps/web/lib/notifications/webPush.ts b/apps/web/lib/notifications/webPush.ts index 40894d601..2a523b25c 100644 --- a/apps/web/lib/notifications/webPush.ts +++ b/apps/web/lib/notifications/webPush.ts @@ -118,7 +118,9 @@ export async function subscribeToPush(): Promise { const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + // TypeScript's DOM lib currently narrows Uint8Array's backing buffer + // more strictly than browsers do for this Web Push API. + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as unknown as BufferSource, }) logger.info("[webPush] Push subscription created") @@ -223,6 +225,11 @@ export async function syncSubscriptionToServer( subscription: PushSubscription, walletAddress: string, preferences?: { + enabled?: boolean + huntEvents?: boolean + rewards?: boolean + social?: boolean + achievements?: boolean huntStart?: boolean overtake?: boolean huntCancelled?: boolean @@ -322,6 +329,11 @@ export async function enablePushNotifications( export async function syncPreferencesToServer( walletAddress: string, preferences: { + enabled?: boolean + huntEvents?: boolean + rewards?: boolean + social?: boolean + achievements?: boolean huntStart?: boolean overtake?: boolean huntCancelled?: boolean diff --git a/apps/web/lib/notifications/weeklyDigest.ts b/apps/web/lib/notifications/weeklyDigest.ts index 7286eee73..b15664944 100644 --- a/apps/web/lib/notifications/weeklyDigest.ts +++ b/apps/web/lib/notifications/weeklyDigest.ts @@ -1,5 +1,6 @@ import { logger } from "@/lib/logger" +import { getNotificationPreferences } from "./notificationPreferences" import { getStoredNotifications, saveNotifications } from "./rankTracker" import type { LeaderboardRankNotification } from "./types" @@ -95,6 +96,9 @@ export function generateWeeklyDigest(): WeeklyDigest | null { } export function shouldSendWeeklyDigest(): boolean { + const prefs = getNotificationPreferences() + if (!prefs.enabled || !prefs.social || !prefs.weeklyDigest) return false + const lastSent = getLastDigestTimestamp() const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 return lastSent < oneWeekAgo @@ -102,6 +106,9 @@ export function shouldSendWeeklyDigest(): boolean { export function createWeeklyDigestNotification(): string | null { try { + const prefs = getNotificationPreferences() + if (!prefs.enabled || !prefs.social || !prefs.weeklyDigest) return null + const digest = generateWeeklyDigest() if (!digest || digest.entries.length === 0) return null diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index dfc4a8088..3d21dda77 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,5 +1,6 @@ -import react from "@vitejs/plugin-react"; import path from "path"; + +import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon @@ -19,7 +20,6 @@ export default defineConfig({ exclude: ["e2e/**", "node_modules/**"], coverage: { provider: "v8", - all: true, reporter: ["text", "json", "html", "lcov"], reportsDirectory: "./coverage", include: ["lib/**/*.{ts,tsx}", "hooks/**/*.{ts,tsx}"], @@ -41,12 +41,23 @@ export default defineConfig({ }, }, resolve: { - alias: { - "@hunty/types/schemas": path.resolve(__dirname, "./packages/types/src/schemas.ts"), - "@hunty/types": path.resolve(__dirname, "./packages/types/src/index.ts"), - "@hunty/types/schemas": path.resolve(__dirname, "../../packages/types/src/schemas.ts"), - "@hunty/types": path.resolve(__dirname, "../../packages/types/src/index.ts"), - "@": path.resolve(__dirname, "./"), - }, + // Keep subpath aliases ahead of the package root alias. Vite matches + // aliases by prefix, so @hunty/types would otherwise swallow + // @hunty/types/api-schemas. + alias: [ + { + find: "@hunty/types/api-schemas", + replacement: path.resolve(__dirname, "../../packages/types/src/api-schemas.ts"), + }, + { + find: "@hunty/types/schemas", + replacement: path.resolve(__dirname, "../../packages/types/src/schemas.ts"), + }, + { + find: "@hunty/types", + replacement: path.resolve(__dirname, "../../packages/types/src/index.ts"), + }, + { find: "@", replacement: path.resolve(__dirname, "./") }, + ], }, }); diff --git a/docs/api.md b/docs/api.md index c58997dbd..99dea3653 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3,15 +3,21 @@ This document describes the public REST API for Hunty. ## Base URL + `/api/v1` ## Authentication + - **GET Endpoints**: Public, no authentication required. +- **Write Endpoints (POST/PUT/DELETE)**: Require an API key passed in the `X-API-Key` header. + _(Note: Current implementation only includes public GET endpoints)_ - **Write Endpoints**: Hunt version writes require the creator's `actorAddress` in the validated request body. The server compares it with the snapshot creator address. ## Rate Limiting + All API endpoints are subject to rate limiting. + - **Limit**: 100 requests per minute per IP address. - **Headers**: - `X-RateLimit-Reset`: Unix timestamp when the limit resets. @@ -22,11 +28,13 @@ All API endpoints are subject to rate limiting. ## Endpoints ### 1. List Public Active Hunts + `GET /hunts` Returns a paginated list of all active public hunts. **Query Parameters:** + - `page` (optional): Page number (default: 1). - `limit` (optional): Items per page (default: 10, max: 100). @@ -34,6 +42,7 @@ Returns a paginated list of all active public hunts. `GET /api/v1/hunts?page=1&limit=2` **Example Response:** + ```json { "data": [ @@ -72,6 +81,7 @@ Returns a paginated list of all active public hunts. ``` ### 2. Get Hunt Details + `GET /hunts/[id]` Returns detailed information about a specific hunt. @@ -80,6 +90,7 @@ Returns detailed information about a specific hunt. `GET /api/v1/hunts/1` **Example Response:** + ```json { "data": { @@ -99,9 +110,12 @@ Returns detailed information about a specific hunt. ``` **Errors:** + - `404 Not Found`: If the hunt ID does not exist. - `403 Forbidden`: If the hunt is private. +### 3. Get Hunt Leaderboard + ### 3. Version a Hunt Edit `PATCH /hunts/[id]` @@ -147,6 +161,7 @@ to their current hunt projection. Returns the paginated leaderboard for a specific hunt. **Query Parameters:** + - `page` (optional): Page number (default: 1). - `limit` (optional): Items per page (default: 10, max: 100). @@ -154,6 +169,7 @@ Returns the paginated leaderboard for a specific hunt. `GET /api/v1/hunts/1/leaderboard?page=1&limit=5` **Example Response:** + ```json { "data": [ @@ -191,4 +207,31 @@ Returns the paginated leaderboard for a specific hunt. ``` **Errors:** + - `404 Not Found`: If the hunt ID does not exist. + +### 4. Notification Preferences + +Notification preferences are scoped to a connected player's wallet, so they +follow the player between web and mobile devices. + +- `GET /api/v1/notifications/preferences?walletAddress=` — read the + complete preference document. +- `PUT /api/v1/notifications/preferences` — merge a preference patch. + +```json +{ + "walletAddress": "G...", + "preferences": { + "enabled": false, + "huntEvents": true, + "rewards": false, + "social": true, + "achievements": true + } +} +``` + +`enabled` is the global mute. It overrides every category and notification +channel. The category flags (`huntEvents`, `rewards`, `social`, and +`achievements`) are independent of one another. diff --git a/docs/persistence-strategy.md b/docs/persistence-strategy.md index 5604af149..a5ed79e8d 100644 --- a/docs/persistence-strategy.md +++ b/docs/persistence-strategy.md @@ -4,12 +4,12 @@ This document answers: **"Where does non-blockchain state live?"** ## Summary -| Layer | What lives there | Technology | -| -------------------------- | ------------------------------------------------------------------ | ------------------------------ | -| On-chain (Stellar/Soroban) | Hunt definitions, reward escrow, NFT receipts, player registration | Soroban smart contracts | -| IPFS (Pinata) | Hunt cover images, NFT media, NFT metadata JSON | Pinata + public IPFS | -| PostgreSQL database | All mutable server-side application state (see below) | `postgres` (porsager/postgres) | -| localStorage (client) | UI prefs, offline-first draft cache, wallet session | Browser only — not canonical | +| Layer | What lives there | Technology | +| -------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| On-chain (Stellar/Soroban) | Hunt definitions, reward escrow, NFT receipts, player registration | Soroban smart contracts | +| IPFS (Pinata) | Hunt cover images, NFT media, NFT metadata JSON | Pinata + public IPFS | +| PostgreSQL database | All mutable server-side application state (see below) | `postgres` (porsager/postgres) | +| localStorage (client) | Cached notification preferences, offline-first draft cache, wallet session | Browser cache — server is canonical for notification preferences | --- @@ -40,6 +40,16 @@ The PostgreSQL database (connection string in `DATABASE_URL`) is the canonical s ### Schema (migrations in `apps/web/lib/db/migrations/`) +| Migration file | Table(s) | Purpose | +| -------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `001_create_app_settings.sql` | `app_settings` | Generic key/value store; currently holds `featured_hunt_id` | +| `002_create_rate_limit.sql` | `rate_limit` | Distributed rate-limit counters (replaces in-memory Map) | +| `003_create_moderation_tables.sql` | `moderation_queue`, `moderation_notifications` | Moderation review queue and creator notifications | +| `004_create_anti_cheat_tables.sql` | `anti_cheat_answers`, `anti_cheat_anomalies`, `anti_cheat_bans`, `anti_cheat_tracking` | Answer history, anomaly detection, bans, per-key submission tracking | +| `005_create_hunt_drafts.sql` | `hunt_drafts` | Cloud-synced creator draft auto-saves | +| `008_create_analytics.sql` | `hunt_views`, `hint_usage_events` | Hunt view counters and hint-reveal event log (replaces `data/hunt-views.json`, `data/hint-usage.json`) | +| `009_create_hunt_analytics.sql` | `hunt_analytics` | Per-hunt analytics: views, starts, completions, clue drop-off, demographics, time-series (replaces `data/hunt-analytics.json`) | +| `010_add_notification_preferences.sql` | `notification_preferences` | Wallet-scoped notification categories and global mute, shared across devices | | Migration file | Table(s) | Purpose | | ---------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `001_create_app_settings.sql` | `app_settings` | Generic key/value store; currently holds `featured_hunt_id` | @@ -57,6 +67,10 @@ The PostgreSQL database (connection string in `DATABASE_URL`) is the canonical s Single row in `app_settings` under `key = 'featured_hunt_id'`. Uses UPSERT so rotating the featured hunt is always consistent across instances. +#### Notification preferences (`api/v1/notifications/preferences`) + +`notification_preferences` stores one normalized JSONB document per wallet. Web and mobile keep a local cache for offline rendering, but read and write the wallet-scoped database document whenever a wallet is connected. The `enabled` field is the global mute and is checked before category-specific delivery. + #### Rate limiting (`lib/rate-limit.ts`) Each call does a single atomic UPSERT: increment `count` for the `(key, expires_at)` window pair. Graceful degradation: if the database is unreachable the request is allowed through rather than dropped. Stale rows (where `expires_at < NOW()`) can be pruned periodically. diff --git a/packages/types/src/api-schemas.ts b/packages/types/src/api-schemas.ts index acad2aa14..977050ff3 100644 --- a/packages/types/src/api-schemas.ts +++ b/packages/types/src/api-schemas.ts @@ -178,6 +178,37 @@ export const pushTokenDeleteBodySchema = z // ─── Moderation / Submit ───────────────────────────────────────────────────── +// ─── Notification preferences ──────────────────────────────────────────────── + +/** Preferences are a partial document on write; the API merges the patch with the saved document. */ +export const notificationPreferencesPatchSchema = z.object({ + enabled: z.boolean().optional(), + huntEvents: z.boolean().optional(), + rewards: z.boolean().optional(), + social: z.boolean().optional(), + achievements: z.boolean().optional(), + rankImproved: z.boolean().optional(), + rankDropped: z.boolean().optional(), + overtaken: z.boolean().optional(), + weeklyDigest: z.boolean().optional(), + threshold: z.number().int().min(1).optional(), + pushEnabled: z.boolean().optional(), + pushHuntStart: z.boolean().optional(), + pushOvertake: z.boolean().optional(), + pushHuntCancelled: z.boolean().optional(), + pushPlayerRegistered: z.boolean().optional(), + pushFirstCompletion: z.boolean().optional(), +}) + +export const notificationPreferencesQuerySchema = z.object({ + walletAddress: nonEmptyStringSchema, +}) + +export const notificationPreferencesBodySchema = z.object({ + walletAddress: nonEmptyStringSchema, + preferences: notificationPreferencesPatchSchema, +}) + export const moderationSubmitBodySchema = z.object({ hunt: z .object({ @@ -430,6 +461,9 @@ export const apiSchemas = { pushTokenRegister: pushTokenRegisterBodySchema, achievementShowcase: achievementShowcaseBodySchema, pushTokenDelete: pushTokenDeleteBodySchema, + notificationPreferencesPatch: notificationPreferencesPatchSchema, + notificationPreferencesQuery: notificationPreferencesQuerySchema, + notificationPreferencesBody: notificationPreferencesBodySchema, moderationSubmitBody: moderationSubmitBodySchema, moderationSyncBody: moderationSyncBodySchema, moderationSyncQuery: moderationSyncQuerySchema,