social overhaul
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user