fp/services/our/src/tasks/consolidate_twitch_channel_rewards.ts

183 lines
6.1 KiB
TypeScript

// src/tasks/consolidate_twitch_channel_rewards.ts
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient, User, type Pick } from '../../generated/prisma';
import { withAccelerate } from "@prisma/extension-accelerate";
import { env } from "../config/env";
import { constants } from "../config/constants";
import { getRateLimiter } from "../utils/rateLimiter";
const prisma = new PrismaClient().$extends(withAccelerate());
const cprPath = env.TWITCH_MOCK ? constants.twitch.dev.paths.channelPointRewards : constants.twitch.prod.paths.channelPointRewards;
interface Payload {
userId: number;
}
export interface TwitchChannelPointReward {
id: string;
broadcaster_id: string;
cost: number;
title: string;
is_in_stock: boolean;
[key: string]: any;
}
const getAuthToken = (user: User) => env.TWITCH_MOCK ? env.TWITCH_MOCK_USER_ACCESS_TOKEN : user.twitchToken?.accessToken;
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload");
if (typeof payload.userId !== "number") throw new Error("invalid payload.userId");
}
const getTwitchChannelPointRewards = async (user: User) => {
if (!user?.twitchToken) throw new Error("Missing Twitch token");
const authToken = getAuthToken(user);
const limiter = getRateLimiter();
await limiter.consume('twitch', 1);
const query = new URLSearchParams({ broadcaster_id: user.twitchId });
const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'Client-Id': env.TWITCH_CLIENT_ID
}
});
if (!res.ok) throw new Error(`Failed to fetch rewards: ${res.statusText}`);
return res.json();
};
const createTwitchReward = async (user: User, pick: Pick) => {
const authToken = getAuthToken(user);
const limiter = getRateLimiter();
await limiter.consume('twitch', 1);
console.log('pick as follows')
console.log(pick)
console.log(`pick?.waifu?.name=${pick?.waifu?.name}`)
const query = new URLSearchParams({ broadcaster_id: user.twitchId });
const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Client-Id': env.TWITCH_CLIENT_ID
},
body: JSON.stringify({ cost: user.redeemCost, title: pick.waifu.name })
});
if (!res.ok) throw new Error(`Failed to create reward: ${res.statusText}`);
const data = await res.json();
const rewardId = data.data?.[0]?.id;
if (!rewardId) throw new Error("No reward ID returned");
await prisma.pick.update({
where: { id: pick.id },
data: { twitchChannelPointRewardId: rewardId }
});
};
const deleteTwitchReward = async (user: User, rewardId: string) => {
const authToken = getAuthToken(user);
const limiter = getRateLimiter();
await limiter.consume('twitch', 1);
const query = new URLSearchParams({ broadcaster_id: user.twitchId, id: rewardId });
const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authToken}`,
'Client-Id': env.TWITCH_CLIENT_ID
}
});
if (!res.ok) throw new Error(`Failed to delete reward ${rewardId}`);
};
const updateTwitchReward = async (user: User, rewardId: string, newCost: number) => {
const authToken = getAuthToken(user);
const limiter = getRateLimiter();
await limiter.consume('twitch', 1);
const query = new URLSearchParams({ broadcaster_id: user.twitchId, id: rewardId });
const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${authToken}`,
'Client-Id': env.TWITCH_CLIENT_ID
},
body: JSON.stringify({ cost: newCost })
});
if (!res.ok) throw new Error(`Failed to update reward ${rewardId}`);
};
const consolidateTwitchRewards = async (userId: number) => {
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
include: { twitchToken: true, Waifu: true }
});
// Fetch picks (most recent N)
const picks = await prisma.pick.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: constants.twitch.maxChannelPointRewards,
include: {
waifu: true
}
});
// Ensure every pick has a reward before processing Twitch side
for (const pick of picks) {
if (!pick.twitchChannelPointRewardId) {
console.log(`Creating new reward for pick: ${pick.id}`);
await createTwitchReward(user, pick);
}
}
// Refresh picks after reward creation
const updatedPicks = await prisma.pick.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: constants.twitch.maxChannelPointRewards
});
// Get the most recent N reward IDs
const currentPickIds = new Set(
updatedPicks.slice(0, user.waifuChoicePoolSize).map(p => p.twitchChannelPointRewardId)
);
console.log('currentPickIds as follows');
console.log(currentPickIds);
// Fetch Twitch-side rewards
const twitchData = await getTwitchChannelPointRewards(user);
const twitchRewards: TwitchChannelPointReward[] = twitchData.data;
// Delete or update Twitch rewards not in current pick set
for (const reward of twitchRewards) {
if (!updatedPicks.some(p => p.twitchChannelPointRewardId === reward.id)) continue;
if (!currentPickIds.has(reward.id)) {
console.log(`Deleting out-of-date reward: ${reward.id}`);
await deleteTwitchReward(user, reward.id);
} else if (reward.cost !== user.redeemCost) {
console.log(`Updating reward cost for: ${reward.id}`);
await updateTwitchReward(user, reward.id, user.redeemCost);
}
}
};
const task: Task = async (payload: any, helpers) => {
assertPayload(payload);
await consolidateTwitchRewards(payload.userId);
};
export default task;