207 lines
4.9 KiB
TypeScript
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 };
|
|
} |