fp/services/worker/src/processors/syncronizePatreon.ts
2025-11-12 07:54:01 -08:00

207 lines
4.9 KiB
TypeScript

import { type Job } from 'bullmq';
import { getPocketBaseClient } from '../util/pocketbase';
import { subMinutes } from 'date-fns'; // optional, or use plain JS Date
interface PatreonMember {
id: string;
type: 'member';
attributes: Record<string, unknown>;
relationships: {
currently_entitled_tiers: {
data: PatreonTierRef[];
};
user: {
data: PatreonUserRef;
links?: {
related?: string;
};
};
};
}
export interface PatreonTierRef {
id: string;
type: 'tier';
}
export interface PatreonUserRef {
id: string;
type: 'user';
}
export type PatreonIncluded = PatreonUser | PatreonTier | PatreonMember;
export interface PatreonUser {
id: string;
type: 'user';
attributes: {
full_name: string;
vanity: string | null;
};
}
export interface PatreonTier {
id: string;
type: 'tier';
attributes: Record<string, unknown>;
}
export interface PatreonUserResponse {
data: PatreonUserData;
included?: PatreonIncluded[];
links?: {
self?: string;
};
}
export interface PatreonUserData {
id: string;
type: 'user';
attributes: {
email: string;
first_name: string;
last_name: string;
full_name: string;
vanity: string | null;
about: string | null;
image_url: string;
thumb_url: string;
created: string; // ISO date string
url: string;
};
relationships?: {
memberships?: {
data: {
id: string;
type: 'member';
}[];
};
};
}
export interface PatreonTier {
id: string;
type: 'tier';
attributes: Record<string, unknown>;
relationships?: {
benefits?: {
data: { id: string; type: string }[];
};
};
}
export const PatreonTiers = [
{ name: 'ArchiveSupporter', id: '8154170', role: 'supporterTier1' },
{ name: 'StealthSupporter', id: '9561793', role: 'supporterTier1' },
{ name: 'TuneItUp', id: '9184994', role: 'supporterTier2' },
{ name: 'MaxQ', id: '22529959', role: 'supporterTier3' },
{ name: 'ArchiveCollector', id: '8154171', role: 'supporterTier4' },
{ name: 'AdvancedArchiveSupporter', id: '8686045', role: 'supporterTier4' },
{ name: 'QuantumSupporter', id: '8694826', role: 'supporterTier5' },
{ name: 'SneakyQuantumSupporter', id: '9560538', role: 'supporterTier5' },
{ name: 'LuberPlusPlus', id: '8686022', role: 'supporterTier6' }
];
interface PatreonMember {
userId: string;
}
// small output type
export interface SimplePatreonMember { userId: string }
export async function getPatreonPatronStatus(
job: Job,
accessToken: string
): Promise<boolean> {
const url =
'https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=about,created,email,first_name,full_name,image_url,last_name,thumb_url,url,vanity&include=memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits';
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
throw new Error(`Patreon API error: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as PatreonUserResponse;
const included = data.included ?? [];
const memberObjects = included.filter((item) => item.type === 'member');
job.log(`Found ${memberObjects.length} membership(s)`);
const validTierIds = PatreonTiers.map((tier) => tier.id);
for (const member of memberObjects) {
const tiers = member.relationships?.currently_entitled_tiers?.data ?? [];
job.log(`Membership ${member.id} has tiers: ${tiers.map(t => t.id)}`);
// Check if any tier is in our whitelist
const hasValidTier = tiers.some((tier) => validTierIds.includes(tier.id));
if (hasValidTier) {
job.log(`User has a valid Patreon tier: ${tiers.map(t => t.id).join(', ')}`);
return true;
}
}
job.log('No valid Patreon tiers found for this user');
return false;
}
async function idempotentlySetUserPatronStatus(
userId: string,
isPatron: boolean
): Promise<void> {
const pb = await getPocketBaseClient();
const presentUser = await pb.collection('users').getOne(userId);
// Only update if the value is actually different
if (presentUser.patron !== isPatron) {
await pb.collection('users').update(userId, {
patron: isPatron
});
}
}
export async function syncronizePatreon(job: Job) {
const pb = await getPocketBaseClient();
job.log('WE GOT pb CLIENT, WOOHOO!');
job.log(`the job '${job.name}' is running`);
const fewMinutesAgo = subMinutes(new Date(), 1);
const recentlyLoggedInUsers = await pb.collection('users').getFullList({
filter: pb.filter('updated>={:since}', { since: fewMinutesAgo })
});
job.log(`recentlyLoggedInUsers:${JSON.stringify(recentlyLoggedInUsers)}`);
let results = []
for (const user of recentlyLoggedInUsers) {
const isPatron = await getPatreonPatronStatus(job, user.patreonAccessToken);
await idempotentlySetUserPatronStatus(user.id, isPatron);
results.push({ user: user.id, isPatron })
}
return { complete: true, results };
}