395 lines
10 KiB
TypeScript
395 lines
10 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { decryptTokens, encryptTokens } from './encryption'
|
|
import { refreshAccessToken, shouldRefreshToken, isRefreshTokenExpired } from './oauth'
|
|
import { getUserInfo, getAllVideos, isTokenExpiredError } from './api'
|
|
import type { TikTokAccount, TikTokDailyStats, TikTokVideo, TikTokSyncType } from '@/types'
|
|
|
|
interface SyncResult {
|
|
success: boolean
|
|
statsSynced: boolean
|
|
videosSynced: number
|
|
newVideos: number
|
|
error?: string
|
|
errorCode?: string
|
|
}
|
|
|
|
/**
|
|
* Ensure access token is valid, refresh if needed
|
|
* Returns the valid access token or throws if unable to refresh
|
|
*/
|
|
export async function ensureValidToken(account: TikTokAccount): Promise<string> {
|
|
const supabase = await createClient()
|
|
|
|
// Get encrypted tokens from database
|
|
const { data: fullAccount, error: fetchError } = await supabase
|
|
.from('tiktok_accounts')
|
|
.select('access_token_encrypted, refresh_token_encrypted')
|
|
.eq('id', account.id)
|
|
.single()
|
|
|
|
if (fetchError || !fullAccount) {
|
|
throw new Error('Failed to fetch account tokens')
|
|
}
|
|
|
|
const { access_token, refresh_token } = decryptTokens(
|
|
fullAccount.access_token_encrypted,
|
|
fullAccount.refresh_token_encrypted
|
|
)
|
|
|
|
const tokenExpiresAt = new Date(account.token_expires_at)
|
|
const refreshTokenExpiresAt = new Date(account.refresh_token_expires_at)
|
|
|
|
// Check if refresh token is expired
|
|
if (isRefreshTokenExpired(refreshTokenExpiresAt)) {
|
|
// Mark account as expired
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({ status: 'expired', last_error: 'Refresh token expired' })
|
|
.eq('id', account.id)
|
|
|
|
throw new Error('Refresh token expired - reconnection required')
|
|
}
|
|
|
|
// Check if access token needs refresh
|
|
if (shouldRefreshToken(tokenExpiresAt)) {
|
|
try {
|
|
const newTokens = await refreshAccessToken(refresh_token)
|
|
const encrypted = encryptTokens(newTokens.access_token, newTokens.refresh_token)
|
|
|
|
const now = new Date()
|
|
const newTokenExpiresAt = new Date(now.getTime() + newTokens.expires_in * 1000)
|
|
const newRefreshExpiresAt = new Date(now.getTime() + newTokens.refresh_expires_in * 1000)
|
|
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({
|
|
...encrypted,
|
|
token_expires_at: newTokenExpiresAt.toISOString(),
|
|
refresh_token_expires_at: newRefreshExpiresAt.toISOString(),
|
|
error_count: 0,
|
|
last_error: null,
|
|
})
|
|
.eq('id', account.id)
|
|
|
|
// Log the token refresh
|
|
await supabase.from('tiktok_sync_logs').insert({
|
|
tiktok_account_id: account.id,
|
|
user_id: account.user_id,
|
|
sync_type: 'token_refresh',
|
|
status: 'success',
|
|
})
|
|
|
|
return newTokens.access_token
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Token refresh failed'
|
|
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({
|
|
status: 'error',
|
|
last_error: errorMessage,
|
|
error_count: account.error_count + 1,
|
|
})
|
|
.eq('id', account.id)
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
return access_token
|
|
}
|
|
|
|
/**
|
|
* Sync daily stats for an account
|
|
*/
|
|
export async function syncDailyStats(account: TikTokAccount, accessToken: string): Promise<boolean> {
|
|
const supabase = await createClient()
|
|
const today = new Date().toISOString().split('T')[0]
|
|
|
|
try {
|
|
const userInfo = await getUserInfo({
|
|
accessToken,
|
|
userId: account.user_id,
|
|
})
|
|
|
|
// Insert or update daily stats
|
|
const { error } = await supabase
|
|
.from('tiktok_daily_stats')
|
|
.upsert({
|
|
tiktok_account_id: account.id,
|
|
user_id: account.user_id,
|
|
stats_date: today,
|
|
follower_count: userInfo.follower_count,
|
|
following_count: userInfo.following_count,
|
|
likes_count: userInfo.likes_count,
|
|
video_count: userInfo.video_count,
|
|
}, {
|
|
onConflict: 'tiktok_account_id,stats_date',
|
|
})
|
|
|
|
if (error) {
|
|
console.error('Failed to save daily stats:', error)
|
|
return false
|
|
}
|
|
|
|
// Update account with latest sync time
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({
|
|
last_stats_sync_at: new Date().toISOString(),
|
|
display_name: userInfo.display_name,
|
|
avatar_url: userInfo.avatar_url,
|
|
})
|
|
.eq('id', account.id)
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error('Failed to sync daily stats:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync videos for an account
|
|
*/
|
|
export async function syncVideos(account: TikTokAccount, accessToken: string): Promise<{ synced: number; new: number }> {
|
|
const supabase = await createClient()
|
|
|
|
try {
|
|
const videos = await getAllVideos({
|
|
accessToken,
|
|
userId: account.user_id,
|
|
})
|
|
|
|
let syncedCount = 0
|
|
let newCount = 0
|
|
|
|
for (const video of videos) {
|
|
// Check if video already exists
|
|
const { data: existing } = await supabase
|
|
.from('tiktok_videos')
|
|
.select('id')
|
|
.eq('tiktok_account_id', account.id)
|
|
.eq('tiktok_video_id', video.id)
|
|
.single()
|
|
|
|
const videoData = {
|
|
tiktok_account_id: account.id,
|
|
user_id: account.user_id,
|
|
tiktok_video_id: video.id,
|
|
title: video.title || video.video_description || null,
|
|
share_url: video.share_url,
|
|
cover_image_url: video.cover_image_url,
|
|
view_count: video.view_count,
|
|
like_count: video.like_count,
|
|
comment_count: video.comment_count,
|
|
share_count: video.share_count,
|
|
duration: video.duration,
|
|
published_at: new Date(video.create_time * 1000).toISOString(),
|
|
}
|
|
|
|
if (existing) {
|
|
// Update existing video metrics
|
|
await supabase
|
|
.from('tiktok_videos')
|
|
.update(videoData)
|
|
.eq('id', existing.id)
|
|
} else {
|
|
// Insert new video
|
|
await supabase.from('tiktok_videos').insert(videoData)
|
|
newCount++
|
|
}
|
|
|
|
syncedCount++
|
|
}
|
|
|
|
// Update account with latest video sync time
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({ last_video_sync_at: new Date().toISOString() })
|
|
.eq('id', account.id)
|
|
|
|
return { synced: syncedCount, new: newCount }
|
|
} catch (error) {
|
|
console.error('Failed to sync videos:', error)
|
|
return { synced: 0, new: 0 }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full sync for an account
|
|
*/
|
|
export async function syncAccount(
|
|
account: TikTokAccount,
|
|
syncType: TikTokSyncType = 'full'
|
|
): Promise<SyncResult> {
|
|
const supabase = await createClient()
|
|
const result: SyncResult = {
|
|
success: false,
|
|
statsSynced: false,
|
|
videosSynced: 0,
|
|
newVideos: 0,
|
|
}
|
|
|
|
// Create sync log entry
|
|
const { data: syncLog } = await supabase
|
|
.from('tiktok_sync_logs')
|
|
.insert({
|
|
tiktok_account_id: account.id,
|
|
user_id: account.user_id,
|
|
sync_type: syncType,
|
|
status: 'started',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
try {
|
|
// Ensure valid token
|
|
const accessToken = await ensureValidToken(account)
|
|
|
|
// Sync based on type
|
|
if (syncType === 'full' || syncType === 'stats') {
|
|
result.statsSynced = await syncDailyStats(account, accessToken)
|
|
}
|
|
|
|
if (syncType === 'full' || syncType === 'videos') {
|
|
const videoResult = await syncVideos(account, accessToken)
|
|
result.videosSynced = videoResult.synced
|
|
result.newVideos = videoResult.new
|
|
}
|
|
|
|
result.success = true
|
|
|
|
// Update sync log
|
|
if (syncLog) {
|
|
await supabase
|
|
.from('tiktok_sync_logs')
|
|
.update({
|
|
status: 'success',
|
|
stats_synced: result.statsSynced,
|
|
videos_synced: result.videosSynced,
|
|
new_videos: result.newVideos,
|
|
completed_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', syncLog.id)
|
|
}
|
|
|
|
// Update account last synced
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({
|
|
last_synced_at: new Date().toISOString(),
|
|
status: 'active',
|
|
error_count: 0,
|
|
last_error: null,
|
|
})
|
|
.eq('id', account.id)
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
const errorCode = isTokenExpiredError(error) ? 'token_expired' : 'sync_failed'
|
|
|
|
result.error = errorMessage
|
|
result.errorCode = errorCode
|
|
|
|
// Update sync log
|
|
if (syncLog) {
|
|
await supabase
|
|
.from('tiktok_sync_logs')
|
|
.update({
|
|
status: 'failed',
|
|
error_message: errorMessage,
|
|
error_code: errorCode,
|
|
completed_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', syncLog.id)
|
|
}
|
|
|
|
// Update account error status
|
|
await supabase
|
|
.from('tiktok_accounts')
|
|
.update({
|
|
status: isTokenExpiredError(error) ? 'expired' : 'error',
|
|
last_error: errorMessage,
|
|
error_count: account.error_count + 1,
|
|
})
|
|
.eq('id', account.id)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Sync all active accounts (for cron job)
|
|
*/
|
|
export async function syncAllAccounts(): Promise<{
|
|
total: number
|
|
successful: number
|
|
failed: number
|
|
}> {
|
|
const supabase = await createClient()
|
|
|
|
// Get all active accounts
|
|
const { data: accounts, error } = await supabase
|
|
.from('tiktok_accounts')
|
|
.select('*')
|
|
.eq('status', 'active')
|
|
|
|
if (error || !accounts) {
|
|
return { total: 0, successful: 0, failed: 0 }
|
|
}
|
|
|
|
let successful = 0
|
|
let failed = 0
|
|
|
|
for (const account of accounts) {
|
|
const result = await syncAccount(account as TikTokAccount)
|
|
if (result.success) {
|
|
successful++
|
|
} else {
|
|
failed++
|
|
}
|
|
|
|
// Small delay between accounts
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
|
|
return { total: accounts.length, successful, failed }
|
|
}
|
|
|
|
/**
|
|
* Refresh tokens for accounts expiring soon (for cron job)
|
|
*/
|
|
export async function refreshExpiringTokens(): Promise<number> {
|
|
const supabase = await createClient()
|
|
|
|
// Get accounts with tokens expiring in the next 6 hours
|
|
const sixHoursFromNow = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString()
|
|
|
|
const { data: accounts, error } = await supabase
|
|
.from('tiktok_accounts')
|
|
.select('*')
|
|
.eq('status', 'active')
|
|
.lt('token_expires_at', sixHoursFromNow)
|
|
|
|
if (error || !accounts) {
|
|
return 0
|
|
}
|
|
|
|
let refreshed = 0
|
|
|
|
for (const account of accounts) {
|
|
try {
|
|
await ensureValidToken(account as TikTokAccount)
|
|
refreshed++
|
|
} catch (error) {
|
|
console.error(`Failed to refresh token for account ${account.id}:`, error)
|
|
}
|
|
|
|
// Small delay between refreshes
|
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
}
|
|
|
|
return refreshed
|
|
}
|