530 lines
14 KiB
TypeScript
530 lines
14 KiB
TypeScript
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<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 pbFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
|
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<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 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<string, unknown>
|
|
): Promise<void> {
|
|
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<string, unknown>
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<PBRecord | null> {
|
|
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<string | null> {
|
|
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<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;
|
|
}
|