Spaces:
Sleeping
Sleeping
File size: 4,588 Bytes
521a9b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | import { supabase } from './client';
import { logger } from '../utils/logger';
import type { TurnitinJob } from './jobs';
export interface CreateJobParams {
userId: string;
assignmentTargetId: string;
mode: string;
filters: Record<string, unknown>;
inputFileName: string;
inputStoragePath: string;
inputFileSize?: number;
inputFileSha256?: string;
submissionRequestId: string;
}
export interface CreateJobResult {
jobId: string;
created: boolean;
}
export interface UserProfile {
id: string;
email: string;
display_name: string | null;
ticket_balance: number;
role: string;
created_at: string;
updated_at: string;
}
/**
* Create a new Turnitin job while atomically deducting a ticket.
* Uses the database RPC to ensure ticket balance is checked and decremented in one transaction.
* Returns the new job ID.
*/
export async function createJobWithTicket(params: CreateJobParams): Promise<CreateJobResult> {
const { data, error } = await supabase.rpc('create_job_with_ticket_idempotent', {
p_user_id: params.userId,
p_assignment_target_id: params.assignmentTargetId,
p_mode: params.mode,
p_filters: params.filters,
p_input_file_name: params.inputFileName,
p_input_file_path: params.inputStoragePath,
p_input_file_size: params.inputFileSize ?? null,
p_input_file_sha256: params.inputFileSha256 ?? null,
p_submission_request_id: params.submissionRequestId,
});
if (error) {
logger.error('Failed to create job with ticket', { error: error.message, userId: params.userId });
throw error;
}
const row = Array.isArray(data) ? data[0] : data;
if (!row?.job_id) {
throw new Error('Idempotent job creation returned no job ID');
}
return {
jobId: row.job_id as string,
created: row.created === true,
};
}
export async function getJobBySubmissionRequestId(
userId: string,
submissionRequestId: string,
): Promise<TurnitinJob | null> {
const { data, error } = await supabase
.from('turnitin_jobs')
.select('*')
.eq('user_id', userId)
.eq('submission_request_id', submissionRequestId)
.maybeSingle();
if (error) {
logger.error('Failed to find job by submission request ID', {
userId,
error: error.message,
});
throw error;
}
return data as TurnitinJob | null;
}
/**
* Fetch a user's profile including their current ticket balance.
*/
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
const { data, error } = await supabase
.from('user_profiles')
.select('*')
.eq('id', userId)
.single();
if (error) {
if (error.code === 'PGRST116') return null; // Row not found
logger.error('Failed to get user profile', { userId, error: error.message });
throw error;
}
return data as UserProfile;
}
/**
* Admin operation: top up a user's ticket balance.
* Uses database RPC for atomic increment. Returns the new balance.
*/
export async function adminTopupTickets(
adminId: string,
targetUserId: string,
amount: number,
): Promise<number> {
const { data, error } = await supabase.rpc('admin_topup_tickets', {
p_admin_id: adminId,
p_target_user_id: targetUserId,
p_amount: amount,
});
if (error) {
logger.error('Failed to top up tickets', {
adminId,
targetUserId,
amount,
error: error.message,
});
throw error;
}
return data as number;
}
/**
* Cancel a pending job and refund the ticket.
* Uses database RPC for atomic status change + ticket refund.
* Returns true if the job was successfully cancelled.
*/
export async function cancelJob(userId: string, jobId: string): Promise<boolean> {
const { data, error } = await supabase.rpc('cancel_job', {
p_user_id: userId,
p_job_id: jobId,
});
if (error) {
logger.error('Failed to cancel job', { userId, jobId, error: error.message });
throw error;
}
return data as boolean;
}
/**
* Refund a terminal failed job exactly once.
*
* The database RPC locks the job row and checks the ticket ledger before
* incrementing balance, so this remains idempotent across multiple workers,
* stale recovery, and manual/admin retries.
*/
export async function refundFailedJob(jobId: string, reason: string): Promise<boolean> {
const { data, error } = await supabase.rpc('refund_failed_job', {
p_job_id: jobId,
p_reason: reason,
});
if (error) {
logger.error('Failed to refund failed job', {
jobId,
reason,
error: error.message,
});
throw error;
}
return data as boolean;
}
|