Sporalize-api / src /subscriptions.ts
Shoraky's picture
Reduce workspace query waterfalls and bound related reads
57b63b8
Raw History Blame Contribute Delete
3.67 kB
import { supabaseAdmin } from "./supabase.js";
type AssignSubscriptionInput = {
userId: string;
subscriptionId: string;
startDate: string;
endDate: string;
};
/**
* Persist a paid subscription after a payment provider's webhook has been
* authenticated and its payment has been confirmed. Never call this directly
* from a browser request.
*/
export async function assignPaidSubscription(input: AssignSubscriptionInput) {
const { userId, subscriptionId, startDate, endDate } = input;
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
throw new Error("Invalid subscription period.");
}
const { data: plan, error: planError } = await supabaseAdmin
.from("subscriptions")
.select("subscription_id")
.eq("subscription_id", subscriptionId)
.maybeSingle();
if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
if (!plan) throw new Error("Subscription plan was not found.");
const { data, error } = await supabaseAdmin
.from("users_subscriptions")
.upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
.select("user_id,subscription_id,startdate,enddate")
.single();
if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
invalidateSubscriptionCache(userId);
return data;
}
async function loadActiveSubscription(userId: string) {
const today = new Date().toISOString().slice(0, 10);
const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
.select("subscription_id,startdate,enddate,plan:subscriptions(subscription_id,plan,price,player_limit,session_limit)")
.eq("user_id", userId).maybeSingle();
if (error) throw new Error(`Unable to check subscription: ${error.message}`);
if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
// The FK is many-to-one; untyped Supabase clients infer embedded rows as arrays.
const plan = assignment.plan as unknown as {
subscription_id: string; plan: string; price: number;
player_limit: number | null; session_limit: number | null;
} | null;
return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
}
// Brief, per-user cache shared by polling, media and session requests.
const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
export function invalidateSubscriptionCache(userId: string) {
accessCache.delete(userId);
accessRequests.delete(userId);
}
export async function activeSubscriptionFor(userId: string, options: { refresh?: boolean } = {}) {
const cached = accessCache.get(userId);
if (!options.refresh && cached && cached.expires > Date.now()) return cached.value;
const existing = accessRequests.get(userId);
if (existing) return existing;
const pending = loadActiveSubscription(userId).then((value) => {
if (accessRequests.get(userId) === pending) {
if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
}
return value;
}).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
accessRequests.set(userId, pending);
return pending;
}