| import { supabase } from './client'; |
| import { config } from '../config'; |
| import { logger } from '../utils/logger'; |
|
|
| export interface TurnitinAccount { |
| id: string; |
| email: string; |
| password?: string | null; |
| turnitin_status: string; |
| turnitin_quota_limit: number | null; |
| turnitin_quota_remaining: number | null; |
| turnitin_quota_message: string | null; |
| turnitin_quota_detected_at: string | null; |
| turnitin_first_submission_at: string | null; |
| turnitin_next_retry_at: string | null; |
| turnitin_lease_owner: string | null; |
| turnitin_lease_until: string | null; |
| turnitin_last_checked_at: string | null; |
| turnitin_last_login_at: string | null; |
| turnitin_last_success_at: string | null; |
| turnitin_last_error: string | null; |
| turnitin_session_storage_path: string | null; |
| turnitin_pool_key: string | null; |
| created_at: string; |
| updated_at: string; |
| } |
|
|
| export interface AccountQuotaUpdate { |
| turnitin_status?: string; |
| turnitin_quota_limit?: number | null; |
| turnitin_quota_remaining?: number | null; |
| turnitin_quota_message?: string | null; |
| turnitin_quota_detected_at?: string | null; |
| turnitin_next_retry_at?: string | null; |
| turnitin_last_checked_at?: string | null; |
| turnitin_last_success_at?: string | null; |
| turnitin_last_error?: string | null; |
| turnitin_session_storage_path?: string | null; |
| turnitin_pool_key?: string | null; |
| } |
|
|
| export interface AccountPoolState { |
| total: number; |
| available: number; |
| running: number; |
| coolingDown: number; |
| quotaLimited: number; |
| loginFailed: number; |
| disabled: number; |
| } |
|
|
| function isLegacyHardLimitMessage(account: { |
| turnitin_quota_message?: string | null; |
| turnitin_last_error?: string | null; |
| }): boolean { |
| const text = `${account.turnitin_quota_message || ''} ${account.turnitin_last_error || ''}`.toLowerCase(); |
| return ( |
| text.includes('target class or assignment is not available') || |
| text.includes('login failed') || |
| text.includes('could not log in') || |
| text.includes('credential is invalid') || |
| text.includes('account no longer exists') || |
| /class.*drop/.test(text) || |
| /class.*not.*found/.test(text) || |
| /assignment.*not.*found/.test(text) || |
| text.includes('permanently limited') || |
| text.includes('reached 4 submissions') || |
| text.includes('4-submission limit') |
| ); |
| } |
|
|
| function isClaimableLegacyAccount(account: TurnitinAccount, nowMs = Date.now()): boolean { |
| const status = account.turnitin_status || 'available'; |
| const quotaLimit = account.turnitin_quota_limit; |
| const quotaRemaining = account.turnitin_quota_remaining; |
| if (typeof quotaLimit === 'number' && quotaLimit <= 0) return false; |
| const hasQuota = |
| typeof quotaRemaining === 'number' |
| ? quotaRemaining > 0 |
| : status !== 'quota_limited'; |
| const leaseReady = |
| !account.turnitin_lease_until || new Date(account.turnitin_lease_until).getTime() <= nowMs; |
| const retryReady = |
| !account.turnitin_next_retry_at || new Date(account.turnitin_next_retry_at).getTime() <= nowMs; |
|
|
| if (!hasQuota || !leaseReady || !retryReady) return false; |
| if (status === 'available' || status === 'cooling_down' || status === 'running') return true; |
| if (status === 'quota_limited') return typeof quotaRemaining === 'number' && !isLegacyHardLimitMessage(account); |
| return false; |
| } |
|
|
| |
| |
| |
| |
| export async function claimAvailableAccount( |
| workerId: string, |
| poolKey = 'modern_lti', |
| ): Promise<TurnitinAccount | null> { |
| |
| |
| |
| const MAX_STALE_RETRIES = 3; |
| for (let attempt = 0; attempt < MAX_STALE_RETRIES; attempt++) { |
| let { data, error } = await supabase.rpc('claim_turnitin_identity', { |
| p_worker_id: workerId, |
| p_pool_key: poolKey, |
| }); |
|
|
| if ( |
| error && |
| poolKey === 'modern_lti' && |
| /function|schema cache|p_pool_key|claim_turnitin_identity/i.test(error.message) |
| ) { |
| logger.warn('Pool-aware claim RPC is not migrated yet; falling back to legacy modern claim RPC', { |
| error: error.message, |
| }); |
| const fallback = await supabase.rpc('claim_turnitin_identity', { |
| p_worker_id: workerId, |
| }); |
| data = fallback.data; |
| error = fallback.error; |
| } |
|
|
| if (error) { |
| logger.error('Failed to claim available account', { poolKey, error: error.message }); |
| throw error; |
| } |
|
|
| if (!data || (Array.isArray(data) && data.length === 0)) { |
| return null; |
| } |
|
|
| const account = Array.isArray(data) ? data[0] : data; |
| const nextRetryAt = account.turnitin_next_retry_at |
| ? new Date(account.turnitin_next_retry_at).getTime() |
| : null; |
| const quotaRemaining = account.turnitin_quota_remaining; |
| const quotaLimit = account.turnitin_quota_limit; |
| const staleAvailable = |
| (typeof quotaRemaining === 'number' && quotaRemaining <= 0) || |
| (typeof quotaLimit === 'number' && quotaLimit <= 0) || |
| (nextRetryAt !== null && nextRetryAt > Date.now()); |
|
|
| if (!staleAvailable) { |
| return account; |
| } |
|
|
| const exhaustedQuota = |
| (typeof quotaRemaining === 'number' && quotaRemaining <= 0) || |
| (typeof quotaLimit === 'number' && quotaLimit <= 0); |
| const nextStatus = exhaustedQuota ? 'quota_limited' : 'cooling_down'; |
| const message = exhaustedQuota |
| ? 'Account has no remaining Turnitin quota and was removed from rotation.' |
| : 'Account quota is not currently available.'; |
|
|
| logger.warn('Claimed stale unavailable account; removing from immediate rotation', { |
| accountId: account.id, |
| poolKey, |
| quotaLimit, |
| quotaRemaining, |
| nextRetryAt: account.turnitin_next_retry_at, |
| nextStatus, |
| }); |
|
|
| await updateAccountQuota(account.id, { |
| turnitin_status: nextStatus, |
| turnitin_quota_remaining: 0, |
| turnitin_quota_message: message, |
| turnitin_next_retry_at: exhaustedQuota |
| ? null |
| : account.turnitin_next_retry_at || |
| new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), |
| turnitin_last_error: 'Skipped stale account during claim.', |
| }); |
| await releaseAccount(account.id, nextStatus, 'Skipped stale account during claim.'); |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function claimSpecificAccountForResume( |
| identityId: string, |
| workerId: string, |
| ): Promise<TurnitinAccount | null> { |
| const now = new Date().toISOString(); |
| const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString(); |
|
|
| const { data, error } = await supabase |
| .from('generated_identities') |
| .update({ |
| turnitin_status: 'running', |
| turnitin_lease_owner: workerId, |
| turnitin_lease_until: leaseUntil, |
| updated_at: now, |
| }) |
| .eq('id', identityId) |
| .not('turnitin_status', 'in', '(disabled,login_failed)') |
| .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now},turnitin_lease_owner.eq.${workerId}`) |
| .select('*') |
| .maybeSingle(); |
|
|
| if (error) { |
| logger.error('Failed to claim specific resume account', { |
| identityId, |
| error: error.message, |
| }); |
| throw error; |
| } |
|
|
| return (data as TurnitinAccount | null) || null; |
| } |
|
|
| |
| |
| |
| |
| export async function claimSpecificAccount( |
| identityId: string, |
| workerId: string, |
| ): Promise<boolean> { |
| const now = new Date().toISOString(); |
| const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString(); |
| const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString(); |
|
|
| const { data, error } = await supabase |
| .from('generated_identities') |
| .update({ |
| turnitin_status: 'running', |
| turnitin_lease_owner: workerId, |
| turnitin_lease_until: leaseUntil, |
| turnitin_last_checked_at: now, |
| updated_at: now, |
| }) |
| .eq('id', identityId) |
| .in('turnitin_status', ['available', 'cooling_down']) |
| .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`) |
| .or( |
| [ |
| `and(turnitin_status.eq.available,or(turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0),or(turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}))`, |
| `and(turnitin_status.eq.cooling_down,turnitin_next_retry_at.lte.${now})`, |
| ].join(','), |
| ) |
| .select('id') |
| .maybeSingle(); |
|
|
| if (error) { |
| logger.error('Failed to claim specific account', { |
| identityId, |
| error: error.message, |
| }); |
| throw error; |
| } |
|
|
| return Boolean(data); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function getLegacyCooldownAccountsForClassDrop( |
| limit = 2, |
| ): Promise<TurnitinAccount[]> { |
| const now = new Date().toISOString(); |
|
|
| const { data, error } = await supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_pool_key', 'legacy_carta') |
| .eq('turnitin_status', 'cooling_down') |
| .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`) |
| .order('turnitin_next_retry_at', { ascending: true, nullsFirst: true }) |
| .limit(limit); |
|
|
| if (error) { |
| logger.error('Failed to read legacy cooldown accounts for class cleanup', { |
| error: error.message, |
| }); |
| throw error; |
| } |
|
|
| return (data as TurnitinAccount[]) || []; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function claimLegacyCooldownAccountForClassDrop( |
| identityId: string, |
| workerId: string, |
| ): Promise<TurnitinAccount | null> { |
| const now = new Date().toISOString(); |
| const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString(); |
|
|
| const { data, error } = await supabase |
| .from('generated_identities') |
| .update({ |
| turnitin_status: 'running', |
| turnitin_lease_owner: workerId, |
| turnitin_lease_until: leaseUntil, |
| turnitin_last_checked_at: now, |
| updated_at: now, |
| }) |
| .eq('id', identityId) |
| .eq('turnitin_pool_key', 'legacy_carta') |
| .eq('turnitin_status', 'cooling_down') |
| .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`) |
| .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`) |
| .select('*') |
| .maybeSingle(); |
|
|
| if (error) { |
| logger.error('Failed to claim legacy cooldown account for class cleanup', { |
| identityId, |
| error: error.message, |
| }); |
| throw error; |
| } |
|
|
| return (data as TurnitinAccount | null) || null; |
| } |
|
|
| |
| |
| |
| |
| export async function getTurnitinAccountById( |
| identityId: string, |
| ): Promise<TurnitinAccount | null> { |
| const { data, error } = await supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('id', identityId) |
| .maybeSingle(); |
|
|
| if (error) { |
| logger.error('Failed to read Turnitin account', { |
| identityId, |
| error: error.message, |
| }); |
| throw error; |
| } |
|
|
| return (data as TurnitinAccount | null) || null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function claimSpecificAccountForManualQuota( |
| identityId: string, |
| workerId: string, |
| ): Promise<TurnitinAccount | null> { |
| const now = new Date().toISOString(); |
| const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString(); |
|
|
| const { data, error } = await supabase |
| .from('generated_identities') |
| .update({ |
| turnitin_status: 'running', |
| turnitin_lease_owner: workerId, |
| turnitin_lease_until: leaseUntil, |
| turnitin_last_checked_at: now, |
| updated_at: now, |
| }) |
| .eq('id', identityId) |
| .not('turnitin_status', 'in', '(disabled,login_failed)') |
| .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`) |
| .select('*') |
| .maybeSingle(); |
|
|
| if (error) { |
| logger.error('Failed to claim account for manual quota check', { |
| identityId, |
| error: error.message, |
| }); |
| throw error; |
| } |
|
|
| return (data as TurnitinAccount | null) || null; |
| } |
|
|
| |
| |
| |
| |
| export async function countAvailableAccounts(poolKey?: string): Promise<number> { |
| const now = new Date().toISOString(); |
| if (poolKey === 'legacy_carta') { |
| const { data, error } = await supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_pool_key', poolKey) |
| .in('turnitin_status', ['available', 'cooling_down', 'running', 'quota_limited']) |
| .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0') |
| .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0') |
| .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`) |
| .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`); |
|
|
| if (error) { |
| logger.error('Failed to count legacy available accounts', { poolKey, error: error.message }); |
| throw error; |
| } |
|
|
| const nowMs = Date.now(); |
| return ((data as TurnitinAccount[]) || []).filter((account) => |
| isClaimableLegacyAccount(account, nowMs), |
| ).length; |
| } |
|
|
| let query = supabase |
| .from('generated_identities') |
| .select('id', { count: 'exact', head: true }) |
| .eq('turnitin_status', 'available') |
| .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0') |
| .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0') |
| .or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`) |
| .or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`); |
|
|
| if (poolKey) { |
| query = query.eq('turnitin_pool_key', poolKey); |
| } |
|
|
| const { count, error } = await query; |
|
|
| if (error) { |
| if ( |
| poolKey === 'modern_lti' && |
| /turnitin_pool_key|column/i.test(error.message) |
| ) { |
| logger.warn('turnitin_pool_key column is not migrated yet; counting all modern accounts without pool filter'); |
| return countAvailableAccounts(); |
| } |
| logger.error('Failed to count available accounts', { poolKey, error: error.message }); |
| throw error; |
| } |
|
|
| return count || 0; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function getAccountPoolState(poolKey?: string): Promise<AccountPoolState> { |
| let rowsQuery = supabase |
| .from('generated_identities') |
| .select('turnitin_status'); |
|
|
| if (poolKey) { |
| rowsQuery = rowsQuery.eq('turnitin_pool_key', poolKey); |
| } |
|
|
| const [available, { data, error }] = await Promise.all([ |
| countAvailableAccounts(poolKey), |
| rowsQuery, |
| ]); |
|
|
| if (error) { |
| if ( |
| poolKey === 'modern_lti' && |
| /turnitin_pool_key|column/i.test(error.message) |
| ) { |
| logger.warn('turnitin_pool_key column is not migrated yet; reading global account pool state'); |
| return getAccountPoolState(); |
| } |
| logger.error('Failed to read account pool state', { poolKey, error: error.message }); |
| throw error; |
| } |
|
|
| const rows = (data || []) as Array<{ turnitin_status: string | null }>; |
| const counts: AccountPoolState = { |
| total: rows.length, |
| available, |
| running: 0, |
| coolingDown: 0, |
| quotaLimited: 0, |
| loginFailed: 0, |
| disabled: 0, |
| }; |
|
|
| for (const row of rows) { |
| const status = row.turnitin_status || 'available'; |
| if (status === 'running') counts.running++; |
| else if (status === 'cooling_down') counts.coolingDown++; |
| else if (status === 'quota_limited') counts.quotaLimited++; |
| else if (status === 'login_failed') counts.loginFailed++; |
| else if (status === 'disabled') counts.disabled++; |
| } |
|
|
| return counts; |
| } |
|
|
| |
| |
| |
| |
| export async function releaseAccount( |
| identityId: string, |
| nextStatus: string, |
| errorMessage?: string, |
| ): Promise<void> { |
| const { error } = await supabase.rpc('release_turnitin_identity', { |
| p_identity_id: identityId, |
| p_next_status: nextStatus, |
| p_error_message: errorMessage || null, |
| }); |
|
|
| if (error) { |
| logger.error('Failed to release account', { identityId, error: error.message }); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| export async function updateAccountQuota( |
| identityId: string, |
| update: Partial<AccountQuotaUpdate>, |
| ): Promise<void> { |
| const { error } = await supabase |
| .from('generated_identities') |
| .update({ ...update, updated_at: new Date().toISOString() }) |
| .eq('id', identityId); |
|
|
| if (error) { |
| logger.error('Failed to update account quota', { identityId, error: error.message }); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export async function getAccountsForQuotaCheck( |
| order: 'oldest' | 'newest' = 'oldest', |
| poolKey = 'modern_lti', |
| ): Promise<TurnitinAccount[]> { |
| const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString(); |
| const now = new Date().toISOString(); |
|
|
| |
| let availableQuery = supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_status', 'available') |
| .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0') |
| .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0') |
| .or(`turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}`); |
|
|
| if (poolKey) { |
| availableQuery = availableQuery.eq('turnitin_pool_key', poolKey); |
| } |
|
|
| let { data: available, error: err1 } = await availableQuery; |
|
|
| if ( |
| err1 && |
| poolKey === 'modern_lti' && |
| /turnitin_pool_key|column/i.test(err1.message) |
| ) { |
| logger.warn('turnitin_pool_key column is not migrated yet; quota check will use global modern account query'); |
| const fallback = await supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_status', 'available') |
| .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0') |
| .or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0') |
| .or(`turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}`); |
| available = fallback.data; |
| err1 = fallback.error; |
| } |
|
|
| if (err1) { |
| logger.error('Failed to get available accounts for quota check', { error: err1.message }); |
| throw err1; |
| } |
|
|
| |
| let coolingDownQuery = supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_status', 'cooling_down') |
| .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0') |
| .lte('turnitin_next_retry_at', now); |
|
|
| if (poolKey) { |
| coolingDownQuery = coolingDownQuery.eq('turnitin_pool_key', poolKey); |
| } |
|
|
| let { data: coolingDown, error: err2 } = await coolingDownQuery; |
|
|
| if ( |
| err2 && |
| poolKey === 'modern_lti' && |
| /turnitin_pool_key|column/i.test(err2.message) |
| ) { |
| const fallback = await supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_status', 'cooling_down') |
| .or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0') |
| .lte('turnitin_next_retry_at', now); |
| coolingDown = fallback.data; |
| err2 = fallback.error; |
| } |
|
|
| if (err2) { |
| logger.error('Failed to get cooling-down accounts for quota check', { error: err2.message }); |
| throw err2; |
| } |
|
|
| const accounts = [ |
| ...((available as TurnitinAccount[]) || []), |
| ...((coolingDown as TurnitinAccount[]) || []), |
| ]; |
|
|
| return accounts.sort((a, b) => { |
| const aChecked = a.turnitin_last_checked_at |
| ? new Date(a.turnitin_last_checked_at).getTime() |
| : null; |
| const bChecked = b.turnitin_last_checked_at |
| ? new Date(b.turnitin_last_checked_at).getTime() |
| : null; |
|
|
| if (aChecked === null && bChecked === null) { |
| return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); |
| } |
|
|
| if (order === 'oldest') { |
| if (aChecked === null) return -1; |
| if (bChecked === null) return 1; |
| return aChecked - bChecked; |
| } |
|
|
| if (aChecked === null) return 1; |
| if (bChecked === null) return -1; |
| return bChecked - aChecked; |
| }); |
| } |
|
|
| |
| |
| |
| export async function getStaleAccounts(staleSinceMinutes: number): Promise<TurnitinAccount[]> { |
| const cutoff = new Date(Date.now() - staleSinceMinutes * 60 * 1000).toISOString(); |
|
|
| const { data, error } = await supabase |
| .from('generated_identities') |
| .select('*') |
| .eq('turnitin_status', 'running') |
| .lt('turnitin_lease_until', cutoff); |
|
|
| if (error) { |
| logger.error('Failed to get stale accounts', { error: error.message }); |
| throw error; |
| } |
|
|
| return (data as TurnitinAccount[]) || []; |
| } |
|
|
| |
| |
| |
| export async function resetStaleAccount(identityId: string): Promise<void> { |
| const { error } = await supabase |
| .from('generated_identities') |
| .update({ |
| turnitin_status: 'available', |
| turnitin_lease_owner: null, |
| turnitin_lease_until: null, |
| turnitin_last_error: null, |
| updated_at: new Date().toISOString(), |
| }) |
| .eq('id', identityId); |
|
|
| if (error) { |
| logger.error('Failed to reset stale account', { identityId, error: error.message }); |
| throw error; |
| } |
| } |
|
|