Files
2026-03-13 03:43:50 -05:00

511 lines
13 KiB
TypeScript

import { SITE_ID, getPocketBaseConfig } from "@/config/services.config";
const VIP_EMAIL_LINK_COLLECTION = "vip_email_link";
type PBRecord = Record<string, unknown>;
function getPBBaseUrl(): string {
return getPocketBaseConfig().url.replace(/\/$/, "");
}
function escapeFilterValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(
new Set(
values
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
)
);
}
async function listRecords(
collection: string,
filter: string,
adminToken: string,
options: { perPage?: number; sort?: string } = {}
): Promise<PBRecord[]> {
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 fetch(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<string, unknown>
): Promise<void> {
await fetch(`${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<string, unknown>
): Promise<void> {
await fetch(
`${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<void> {
await fetch(
`${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<PBRecord | null> {
const res = await fetch(
`${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<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) {
return null;
}
const baseUrl = getPBBaseUrl();
const paths = [
"/api/admins/auth-with-password",
"/api/collections/_superusers/auth-with-password",
];
for (const path of paths) {
const res = await fetch(`${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) {
return data.token;
}
}
return null;
}
export async function listSiteVipByUserId(
adminToken: string,
userId: string
): Promise<PBRecord[]> {
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<PBRecord[]> {
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<PBRecord[]> {
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<PBRecord | null> {
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<boolean> {
return !!(await getActiveSiteVipRecord(adminToken, userId));
}
export async function getLatestVipPayment(
adminToken: string,
userId: string
): Promise<PBRecord | null> {
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<boolean> {
return !!(await getLatestVipPayment(adminToken, userId));
}
export async function hasVipByUserId(
adminToken: string,
userId: string
): Promise<boolean> {
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<void> {
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<string, unknown> = { user_id: toUserId, site_id: SITE_ID };
if (nextOrderId) {
body.order_id = nextOrderId;
}
await patchRecord("site_vip", canonicalRecordId, adminToken, body);
const duplicateIds = new Set<string>();
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<void> {
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<string, unknown> = {
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<PBRecord | null> {
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<PBRecord | null> {
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<PBRecord | null> {
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<string | null> {
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<string>();
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<string | null> {
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;
}