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; 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; } 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; 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 { 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 { 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 }; }