import { SITE_ID, getPocketBaseConfig } from "@/config/services.config"; const VIP_EMAIL_LINK_COLLECTION = "vip_email_link"; const PB_FETCH_TIMEOUT = Number(process.env.POCKETBASE_FETCH_TIMEOUT) || (process.env.NODE_ENV === "development" ? 30_000 : 15_000); const PB_ADMIN_TOKEN_TTL_MS = Number(process.env.POCKETBASE_ADMIN_TOKEN_TTL_MS) || 55_000; let adminTokenCache: { token: string; expiresAt: number } | null = null; type PBRecord = Record; function getPBBaseUrl(): string { return getPocketBaseConfig().url.replace(/\/$/, ""); } function escapeFilterValue(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } function uniqueStrings(values: Array): string[] { return Array.from( new Set( values .map((value) => (typeof value === "string" ? value.trim() : "")) .filter(Boolean) ) ); } async function pbFetch(input: string, init: RequestInit = {}): Promise { const signal = init.signal ?? AbortSignal.timeout(PB_FETCH_TIMEOUT); return fetch(input, { ...init, signal }); } async function listRecords( collection: string, filter: string, adminToken: string, options: { perPage?: number; sort?: string } = {} ): Promise { const url = new URL(`${getPBBaseUrl()}/api/collections/${collection}/records`); url.searchParams.set("filter", filter); url.searchParams.set("perPage", String(options.perPage ?? 20)); if (options.sort) { url.searchParams.set("sort", options.sort); } const res = await pbFetch(url.toString(), { headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store", }); if (!res.ok) { return []; } const data = await res.json().catch(() => ({})); return Array.isArray(data?.items) ? data.items : []; } async function createRecord( collection: string, adminToken: string, body: Record ): Promise { await pbFetch(`${getPBBaseUrl()}/api/collections/${collection}/records`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}`, }, body: JSON.stringify(body), cache: "no-store", }); } async function patchRecord( collection: string, recordId: string, adminToken: string, body: Record ): Promise { await pbFetch( `${getPBBaseUrl()}/api/collections/${collection}/records/${recordId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}`, }, body: JSON.stringify(body), cache: "no-store", } ); } async function deleteRecord( collection: string, recordId: string, adminToken: string ): Promise { await pbFetch( `${getPBBaseUrl()}/api/collections/${collection}/records/${recordId}`, { method: "DELETE", headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store", } ); } async function getRecord( collection: string, recordId: string, adminToken: string ): Promise { const res = await pbFetch( `${getPBBaseUrl()}/api/collections/${collection}/records/${recordId}`, { headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store", } ); if (!res.ok) { return null; } return (await res.json().catch(() => null)) as PBRecord | null; } function isVipExpired(expiresAt: unknown): boolean { if (typeof expiresAt !== "string" || !expiresAt.trim()) { return false; } const timestamp = Date.parse(expiresAt); return Number.isFinite(timestamp) ? timestamp < Date.now() : false; } export function isAnonymousUserId( value: string | null | undefined ): value is string { return !!value && /^user\d+$/.test(value); } export async function getAdminToken(): Promise { const email = process.env.POCKETBASE_EMAIL; const password = process.env.POCKETBASE_PASSWORD; if (!email || !password) { return null; } if (adminTokenCache && adminTokenCache.expiresAt > Date.now()) { return adminTokenCache.token; } const baseUrl = getPBBaseUrl(); const paths = [ "/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password", ]; for (const path of paths) { const res = await pbFetch(`${baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: email, password }), cache: "no-store", }); if (!res.ok) { continue; } const data = await res.json().catch(() => ({})); if (typeof data?.token === "string" && data.token) { adminTokenCache = { token: data.token, expiresAt: Date.now() + PB_ADMIN_TOKEN_TTL_MS, }; return data.token; } } return null; } export async function listSiteVipByUserId( adminToken: string, userId: string ): Promise { const safeUserId = escapeFilterValue(userId); const safeSiteId = escapeFilterValue(SITE_ID); return listRecords( "site_vip", `user_id = "${safeUserId}" && site_id = "${safeSiteId}"`, adminToken, { perPage: 500, sort: "-created" } ); } export async function listSiteVipByOrderId( adminToken: string, orderId: string ): Promise { const safeOrderId = escapeFilterValue(orderId); const safeSiteId = escapeFilterValue(SITE_ID); return listRecords( "site_vip", `order_id = "${safeOrderId}" && site_id = "${safeSiteId}"`, adminToken, { perPage: 500, sort: "-created" } ); } async function listPaymentsByOrderId( adminToken: string, orderId: string ): Promise { const safeOrderId = escapeFilterValue(orderId); return listRecords( "payments", `order_id = "${safeOrderId}" && type = "vip"`, adminToken, { perPage: 20, sort: "-created" } ); } export async function getActiveSiteVipRecord( adminToken: string, userId: string ): Promise { const records = await listSiteVipByUserId(adminToken, userId); for (const record of records) { if (!isVipExpired(record.expires_at)) { return record; } } return null; } export async function checkSiteVipByUserId( adminToken: string, userId: string ): Promise { return !!(await getActiveSiteVipRecord(adminToken, userId)); } export async function getLatestVipPayment( adminToken: string, userId: string ): Promise { const safeUserId = escapeFilterValue(userId); const records = await listRecords( "payments", `user_id = "${safeUserId}" && type = "vip"`, adminToken, { perPage: 1, sort: "-created" } ); return records[0] ?? null; } export async function checkPaymentsByUserId( adminToken: string, userId: string ): Promise { return !!(await getLatestVipPayment(adminToken, userId)); } export async function hasVipByUserId( adminToken: string, userId: string ): Promise { if (!userId) { return false; } if (await checkSiteVipByUserId(adminToken, userId)) { return true; } return checkPaymentsByUserId(adminToken, userId); } export async function ensureSiteVipForUser( adminToken: string, fromUserId: string, toUserId: string ): Promise { if (!fromUserId || !toUserId || fromUserId === toUserId) { return; } const sourceRecords = await listSiteVipByUserId(adminToken, fromUserId); const targetRecords = await listSiteVipByUserId(adminToken, toUserId); const latestPayment = await getLatestVipPayment(adminToken, fromUserId); const orderId = typeof latestPayment?.order_id === "string" ? latestPayment.order_id.trim() : ""; const orderRecords = orderId ? await listSiteVipByOrderId(adminToken, orderId) : []; const canonicalRecord = targetRecords[0] ?? sourceRecords[0] ?? orderRecords[0] ?? null; const canonicalRecordId = typeof canonicalRecord?.id === "string" ? canonicalRecord.id : ""; if (canonicalRecordId) { const nextOrderId = orderId || (typeof canonicalRecord?.order_id === "string" ? canonicalRecord.order_id.trim() : ""); const body: Record = { user_id: toUserId, site_id: SITE_ID }; if (nextOrderId) { body.order_id = nextOrderId; } await patchRecord("site_vip", canonicalRecordId, adminToken, body); const duplicateIds = new Set(); for (const record of [...targetRecords, ...sourceRecords, ...orderRecords]) { const recordId = typeof record.id === "string" ? record.id : ""; if (recordId && recordId !== canonicalRecordId) { duplicateIds.add(recordId); } } for (const recordId of duplicateIds) { await deleteRecord("site_vip", recordId, adminToken); } return; } if (!orderId) { return; } await createRecord("site_vip", adminToken, { user_id: toUserId, site_id: SITE_ID, order_id: orderId, }); } export async function upsertVipEmailLink( adminToken: string, email: string, userId: string, anonymousUserId?: string ): Promise { if (!email || !userId) { return; } const normalizedEmail = email.trim().toLowerCase(); const safeEmail = escapeFilterValue(normalizedEmail); const records = await listRecords( VIP_EMAIL_LINK_COLLECTION, `email = "${safeEmail}"`, adminToken, { perPage: 1 } ); const body: Record = { email: normalizedEmail, user_id: userId, }; if (anonymousUserId) { body.anonymous_user_id = anonymousUserId; } const recordId = typeof records[0]?.id === "string" ? records[0].id : ""; if (recordId) { await patchRecord(VIP_EMAIL_LINK_COLLECTION, recordId, adminToken, body); return; } await createRecord(VIP_EMAIL_LINK_COLLECTION, adminToken, body); } export async function findVipEmailLinkByEmail( adminToken: string, email: string ): Promise { const candidates = uniqueStrings([email, email.toLowerCase()]); for (const candidate of candidates) { const safeEmail = escapeFilterValue(candidate); const records = await listRecords( VIP_EMAIL_LINK_COLLECTION, `email = "${safeEmail}"`, adminToken, { perPage: 1 } ); if (records[0]) { return records[0]; } } return null; } export async function findVipEmailLinkByUserId( adminToken: string, userId: string ): Promise { const safeUserId = escapeFilterValue(userId); const records = await listRecords( VIP_EMAIL_LINK_COLLECTION, `user_id = "${safeUserId}"`, adminToken, { perPage: 1 } ); return records[0] ?? null; } export async function findVipEmailLinkByAnonymousUserId( adminToken: string, anonymousUserId: string ): Promise { const filters = uniqueStrings([ `anonymous_user_id = "${escapeFilterValue(anonymousUserId)}"`, isAnonymousUserId(anonymousUserId) ? `user_id = "${escapeFilterValue(anonymousUserId)}"` : null, ]); for (const filter of filters) { const records = await listRecords( VIP_EMAIL_LINK_COLLECTION, filter, adminToken, { perPage: 1 } ); if (records[0]) { return records[0]; } } return null; } export async function getUserEmailById( adminToken: string, userId: string ): Promise { const record = await getRecord("users", userId, adminToken); const email = typeof record?.email === "string" ? record.email.trim().toLowerCase() : ""; return email || null; } export async function findLinkedMemberFromOrder( adminToken: string, anonymousUserId: string ): Promise<{ userId: string; email: string } | null> { const safeUserId = escapeFilterValue(anonymousUserId); const payments = await listRecords( "payments", `user_id = "${safeUserId}" && type = "vip"`, adminToken, { perPage: 20, sort: "-created" } ); const seenOrders = new Set(); for (const payment of payments) { const orderId = typeof payment.order_id === "string" ? payment.order_id.trim() : ""; if (!orderId || seenOrders.has(orderId)) { continue; } seenOrders.add(orderId); const siteVipRecords = await listSiteVipByOrderId(adminToken, orderId); for (const record of siteVipRecords) { const linkedUserId = typeof record.user_id === "string" ? record.user_id.trim() : ""; if ( !linkedUserId || linkedUserId === anonymousUserId || isAnonymousUserId(linkedUserId) ) { continue; } const email = await getUserEmailById(adminToken, linkedUserId); if (email) { return { userId: linkedUserId, email }; } } } return null; } export async function findAnonymousUserIdForUser( adminToken: string, userId: string ): Promise { if (isAnonymousUserId(userId)) { return userId; } const linkRecord = await findVipEmailLinkByUserId(adminToken, userId); const linkedAnonymousUserId = typeof linkRecord?.anonymous_user_id === "string" ? linkRecord.anonymous_user_id.trim() : ""; if (isAnonymousUserId(linkedAnonymousUserId)) { return linkedAnonymousUserId; } const siteVipRecords = await listSiteVipByUserId(adminToken, userId); for (const record of siteVipRecords) { const orderId = typeof record.order_id === "string" ? record.order_id.trim() : ""; if (!orderId) { continue; } const payments = await listPaymentsByOrderId(adminToken, orderId); for (const payment of payments) { const anonymousUserId = typeof payment.user_id === "string" ? payment.user_id.trim() : ""; if (isAnonymousUserId(anonymousUserId)) { return anonymousUserId; } } } return null; }