social overhaul

This commit is contained in:
2026-07-22 15:47:34 -07:00
parent 9db3fa697d
commit 86cda294f9
45 changed files with 3026 additions and 430 deletions
+9
View File
@@ -17,3 +17,12 @@ export const serverConfig = {
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '',
}
export const vapidConfig = {
publicKey: process.env.VAPID_PUBLIC_KEY || '',
privateKey: process.env.VAPID_PRIVATE_KEY || '',
subject: process.env.VAPID_SUBJECT || 'mailto:admin@flexxon.app',
}
export const isPushConfigured = () =>
Boolean(vapidConfig.publicKey && vapidConfig.privateKey)
+4 -1
View File
@@ -8,6 +8,7 @@ import { createActivitiesService } from "./services/activities";
import { createBadgesService } from "./services/badges";
import { createGroupsService } from "./services/groups";
import { createPredictionsService } from "./services/predictions";
import { createPushService } from "./services/push";
class PocketBaseAdminClient {
private pb: PocketBase;
@@ -50,6 +51,7 @@ class PocketBaseAdminClient {
Object.assign(this, createBadgesService(this.pb));
Object.assign(this, createGroupsService(this.pb));
Object.assign(this, createPredictionsService(this.pb));
Object.assign(this, createPushService(this.pb));
this.authPromise = this.authenticate();
this.authPromise.then(() => {
@@ -129,7 +131,8 @@ interface AdminClient
ReturnType<typeof createActivitiesService>,
ReturnType<typeof createBadgesService>,
ReturnType<typeof createGroupsService>,
ReturnType<typeof createPredictionsService> {
ReturnType<typeof createPredictionsService>,
ReturnType<typeof createPushService> {
authPromise: Promise<void>;
}
+13 -2
View File
@@ -5,6 +5,10 @@ import { transformMatch } from "../util/transform-types";
export function createMatchesService(pb: PocketBase) {
return {
createBatch() {
return pb.createBatch();
},
async getMatch(id: string): Promise<Match | null> {
const result = await pb.collection("matches").getOne(id, {
expand: "tournament, home, away, home.players, away.players",
@@ -12,7 +16,6 @@ export function createMatchesService(pb: PocketBase) {
return transformMatch(result);
},
// match Ids where the current lid is home_from_lid or away_from_lid
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
const match = await this.getMatch(matchId);
if (!match) throw new Error("Match not found")
@@ -32,7 +35,6 @@ export function createMatchesService(pb: PocketBase) {
},
async createMatch(data: MatchInput): Promise<Match> {
// logger.info("PocketBase | Creating match", data);
const result = await pb.collection("matches").create<Match>(data);
return result;
},
@@ -142,6 +144,15 @@ export function createMatchesService(pb: PocketBase) {
return results.map(match => transformMatch(match));
},
async getMatchesByTournament(tournamentId: string): Promise<Match[]> {
const results = await pb.collection("matches").getFullList({
filter: `tournament = "${tournamentId}"`,
expand: "tournament, home, away, home.players, away.players",
sort: "lid",
});
return results.map(match => transformMatch(match));
},
async getMatchesByGroup(groupId: string): Promise<Match[]> {
logger.info("PocketBase | Getting matches for group", { groupId });
+71
View File
@@ -0,0 +1,71 @@
import PocketBase from "pocketbase";
export interface PushSubscriptionRecord {
id: string;
player: string;
endpoint: string;
p256dh: string;
auth: string;
user_agent?: string;
created: string;
updated: string;
}
export interface PushSubscriptionInput {
player: string;
endpoint: string;
p256dh: string;
auth: string;
user_agent?: string;
}
export const createPushService = (pb: PocketBase) => ({
async listPushSubscriptionsByPlayer(
playerId: string
): Promise<PushSubscriptionRecord[]> {
return await pb
.collection("push_subscriptions")
.getFullList<PushSubscriptionRecord>({
filter: `player = "${playerId}"`,
});
},
async getPushSubscriptionByEndpoint(
endpoint: string
): Promise<PushSubscriptionRecord | null> {
try {
return await pb
.collection("push_subscriptions")
.getFirstListItem<PushSubscriptionRecord>(
`endpoint = "${endpoint}"`
);
} catch {
return null;
}
},
async upsertPushSubscription(
data: PushSubscriptionInput
): Promise<PushSubscriptionRecord> {
const existing = await this.getPushSubscriptionByEndpoint(data.endpoint);
if (existing) {
return await pb
.collection("push_subscriptions")
.update<PushSubscriptionRecord>(existing.id, data);
}
return await pb
.collection("push_subscriptions")
.create<PushSubscriptionRecord>(data);
},
async deletePushSubscriptionByEndpoint(endpoint: string): Promise<void> {
const existing = await this.getPushSubscriptionByEndpoint(endpoint);
if (existing) {
await pb.collection("push_subscriptions").delete(existing.id);
}
},
async deletePushSubscriptionById(id: string): Promise<void> {
await pb.collection("push_subscriptions").delete(id);
},
});
+5 -3
View File
@@ -6,9 +6,6 @@ import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/
import { Prediction } from "@/features/predictions/types";
import { Activity } from "../services/activities";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
// this file transforms raw pocketbase results to our types
export function transformPlayerInfo(record: any): PlayerInfo {
return {
id: record.id,
@@ -58,6 +55,11 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
away_seed: record.away_seed,
match_type: record.match_type,
group: record.group,
reported_home_cups: record.reported_home_cups ?? undefined,
reported_away_cups: record.reported_away_cups ?? undefined,
reported_ot_count: record.reported_ot_count ?? undefined,
reported_by_team: record.reported_by_team || undefined,
reported_by_player: record.reported_by_player || undefined,
};
}
+111
View File
@@ -0,0 +1,111 @@
import webpush from "web-push";
import { vapidConfig, isPushConfigured } from "@/lib/config";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
import type { PushSubscriptionRecord } from "@/lib/pocketbase/services/push";
export interface PushPayload {
title: string;
body: string;
/** Path (or absolute URL) to open/focus when the notification is clicked. */
url?: string;
/** Optional icon override; defaults to the app icon in the SW handler. */
icon?: string;
/** Optional tag to collapse/replace notifications of the same kind. */
tag?: string;
}
let vapidInitialized = false;
function ensureVapid(): boolean {
if (!isPushConfigured()) {
logger.warn(
"Push | VAPID keys not configured (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY); skipping send"
);
return false;
}
if (!vapidInitialized) {
webpush.setVapidDetails(
vapidConfig.subject,
vapidConfig.publicKey,
vapidConfig.privateKey
);
vapidInitialized = true;
}
return true;
}
/**
* Low-level: send a single push to one subscription. Throws on transport
* errors. Callers that persist subscriptions should handle 404/410 (dead
* endpoint) themselves — see sendPushToPlayer.
*/
export async function sendPush(
subscription: {
endpoint: string;
keys: { p256dh: string; auth: string };
},
payload: PushPayload
): Promise<void> {
if (!ensureVapid()) return;
await webpush.sendNotification(subscription, JSON.stringify(payload));
}
/**
* Send a push to every device a player has subscribed. Dead subscriptions
* (HTTP 404/410 from the push service) are pruned from storage automatically.
*
* This is the seam that real product triggers will call LATER, e.g.:
* - a match the player is in starts (src/lib/pocketbase/services/matches.ts)
* - a free-agent claim involving the player (src/lib/pocketbase/services/free-agents ...)
* - a badge is awarded to the player (src/lib/pocketbase/services/badges.ts)
* NONE of these are wired yet — this foundation pass only exposes the seam
* plus a manual "test notification" path.
*/
export async function sendPushToPlayer(
playerId: string,
payload: PushPayload
): Promise<{ sent: number; failed: number; pruned: number }> {
if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
const subscriptions: PushSubscriptionRecord[] =
await pbAdmin.listPushSubscriptionsByPlayer(playerId);
let sent = 0;
let failed = 0;
let pruned = 0;
await Promise.all(
subscriptions.map(async (sub) => {
try {
await sendPush(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
payload
);
sent++;
} catch (error: any) {
const status = error?.statusCode;
if (status === 404 || status === 410) {
// Subscription no longer valid — prune it.
try {
await pbAdmin.deletePushSubscriptionById(sub.id);
pruned++;
} catch (delError) {
logger.error("Push | Failed to prune dead subscription", delError);
}
} else {
failed++;
logger.error(
`Push | Failed to send to endpoint (status ${status ?? "?"})`,
error?.body ?? error
);
}
}
})
);
return { sent, failed, pruned };
}