File size: 4,588 Bytes
9e212e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
}