/ lib / quota.ts
quota.ts
  1  // ============================================================================
  2  // GapZero — Quota Check & Increment Helpers
  3  // Server-side only. Used by API routes to enforce usage limits.
  4  // ============================================================================
  5  
  6  import type { SupabaseClient } from '@supabase/supabase-js';
  7  import type { QuotaType, QuotaCheck, UserQuotaStatus, UserQuotaRow } from './types';
  8  
  9  /** Column mapping from QuotaType to DB column names */
 10  const USED_COLUMN: Record<QuotaType, keyof UserQuotaRow> = {
 11    analysis: 'analyses_used',
 12    cv_generation: 'cv_generations_used',
 13    cover_letter: 'cover_letters_used',
 14    coach_request: 'coach_requests_used',
 15  };
 16  
 17  const LIMIT_COLUMN: Record<QuotaType, keyof UserQuotaRow> = {
 18    analysis: 'analyses_limit',
 19    cv_generation: 'cv_limit',
 20    cover_letter: 'cover_letter_limit',
 21    coach_request: 'coach_limit',
 22  };
 23  
 24  /** Pro plan limits */
 25  const PRO_LIMITS = {
 26    analyses_limit: 50,
 27    cv_limit: 50,
 28    cover_letter_limit: 50,
 29    coach_limit: 50,
 30  } as const;
 31  
 32  /** Free plan limits */
 33  const FREE_LIMITS = {
 34    analyses_limit: 1,
 35    cv_limit: 1,
 36    cover_letter_limit: 1,
 37    coach_limit: 0,
 38  } as const;
 39  
 40  /** Get next Monday 00:00 UTC as ISO string */
 41  function getNextMonday(): string {
 42    const now = new Date();
 43    const dayOfWeek = now.getUTCDay(); // 0 = Sunday
 44    const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
 45    const nextMonday = new Date(now);
 46    nextMonday.setUTCDate(now.getUTCDate() + daysUntilMonday);
 47    nextMonday.setUTCHours(0, 0, 0, 0);
 48    return nextMonday.toISOString();
 49  }
 50  
 51  /** Ensure quota row exists (lazy-create for users created before migration) */
 52  async function ensureQuotaRow(
 53    client: SupabaseClient,
 54    userId: string
 55  ): Promise<UserQuotaRow> {
 56    const { data, error } = await client
 57      .from('user_quotas')
 58      .select('*')
 59      .eq('user_id', userId)
 60      .single();
 61  
 62    if (data) return data as UserQuotaRow;
 63  
 64    // Row doesn't exist — create it (handles users from before migration)
 65    if (error?.code === 'PGRST116') {
 66      const { data: inserted, error: insertErr } = await client
 67        .from('user_quotas')
 68        .insert({ user_id: userId })
 69        .select('*')
 70        .single();
 71  
 72      if (insertErr) throw new Error(`Failed to create quota row: ${insertErr.message}`);
 73      return inserted as UserQuotaRow;
 74    }
 75  
 76    throw new Error(`Failed to fetch quota: ${error?.message}`);
 77  }
 78  
 79  /**
 80   * Check whether the user has quota remaining for the given type.
 81   * Does NOT increment — call incrementQuota() after the action succeeds.
 82   */
 83  export async function checkQuota(
 84    client: SupabaseClient,
 85    userId: string,
 86    type: QuotaType
 87  ): Promise<QuotaCheck> {
 88    const row = await ensureQuotaRow(client, userId);
 89  
 90    const used = row[USED_COLUMN[type]] as number;
 91    const limit = row[LIMIT_COLUMN[type]] as number;
 92    const resetAt = getNextMonday();
 93  
 94    // Special case: first-ever analysis is free (doesn't count toward limit)
 95    if (type === 'analysis' && !row.has_used_initial_analysis) {
 96      return {
 97        allowed: true,
 98        used,
 99        limit,
100        plan: row.plan,
101        isInitialAnalysis: true,
102        resetAt,
103      };
104    }
105  
106    return {
107      allowed: used < limit,
108      used,
109      limit,
110      plan: row.plan,
111      resetAt,
112    };
113  }
114  
115  /**
116   * Increment the usage counter after a successful action.
117   * For initial analysis, marks the flag instead of incrementing.
118   */
119  export async function incrementQuota(
120    client: SupabaseClient,
121    userId: string,
122    type: QuotaType,
123    isInitialAnalysis = false
124  ): Promise<void> {
125    if (type === 'analysis' && isInitialAnalysis) {
126      const { error } = await client
127        .from('user_quotas')
128        .update({ has_used_initial_analysis: true, updated_at: new Date().toISOString() })
129        .eq('user_id', userId);
130      if (error) throw new Error(`Failed to mark initial analysis used: ${error.message}`);
131      return;
132    }
133  
134    const col = USED_COLUMN[type];
135  
136    // Fetch current value and increment (no RPC needed for simple +1)
137    const row = await ensureQuotaRow(client, userId);
138    const currentVal = row[col] as number;
139  
140    const { error } = await client
141      .from('user_quotas')
142      .update({ [col]: currentVal + 1, updated_at: new Date().toISOString() })
143      .eq('user_id', userId);
144    if (error) throw new Error(`Failed to increment quota for ${type}: ${error.message}`);
145  }
146  
147  /**
148   * Mark a user's initial analysis as used (without incrementing weekly counter).
149   */
150  export async function markInitialAnalysisUsed(
151    client: SupabaseClient,
152    userId: string
153  ): Promise<void> {
154    const { error } = await client
155      .from('user_quotas')
156      .update({ has_used_initial_analysis: true, updated_at: new Date().toISOString() })
157      .eq('user_id', userId);
158    if (error) throw new Error(`Failed to mark initial analysis used: ${error.message}`);
159  }
160  
161  /**
162   * Get full quota status for a user (used by GET /api/quota).
163   */
164  export async function getQuotaStatus(
165    client: SupabaseClient,
166    userId: string
167  ): Promise<UserQuotaStatus> {
168    const row = await ensureQuotaRow(client, userId);
169  
170    return {
171      plan: row.plan,
172      weekStart: row.week_start,
173      resetAt: getNextMonday(),
174      analysis: { used: row.analyses_used, limit: row.analyses_limit },
175      cvGeneration: { used: row.cv_generations_used, limit: row.cv_limit },
176      coverLetter: { used: row.cover_letters_used, limit: row.cover_letter_limit },
177      coachRequest: { used: row.coach_requests_used, limit: row.coach_limit },
178      hasUsedInitialAnalysis: row.has_used_initial_analysis,
179      subscription: row.stripe_subscription_id
180        ? {
181            status: row.subscription_status,
182            periodEnd: row.subscription_period_end,
183          }
184        : null,
185    };
186  }
187  
188  /**
189   * Upgrade a user to Pro plan (called from Stripe webhook).
190   */
191  export async function upgradeToPro(
192    client: SupabaseClient,
193    userId: string,
194    stripeCustomerId: string,
195    stripeSubscriptionId: string,
196    periodEnd: string
197  ): Promise<void> {
198    const { error } = await client
199      .from('user_quotas')
200      .update({
201        plan: 'pro',
202        ...PRO_LIMITS,
203        stripe_customer_id: stripeCustomerId,
204        stripe_subscription_id: stripeSubscriptionId,
205        subscription_status: 'active',
206        subscription_period_end: periodEnd,
207        updated_at: new Date().toISOString(),
208      })
209      .eq('user_id', userId);
210    if (error) throw new Error(`Failed to upgrade user to Pro: ${error.message}`);
211  }
212  
213  /**
214   * Downgrade a user to Free plan (called from Stripe webhook on cancel).
215   */
216  export async function downgradeToFree(
217    client: SupabaseClient,
218    userId: string
219  ): Promise<void> {
220    const { error } = await client
221      .from('user_quotas')
222      .update({
223        plan: 'free',
224        ...FREE_LIMITS,
225        subscription_status: 'canceled',
226        updated_at: new Date().toISOString(),
227      })
228      .eq('user_id', userId);
229    if (error) throw new Error(`Failed to downgrade user to Free: ${error.message}`);
230  }
231  
232  /**
233   * Update subscription status (called from Stripe webhook).
234   */
235  export async function updateSubscriptionStatus(
236    client: SupabaseClient,
237    userId: string,
238    status: 'active' | 'past_due' | 'canceled' | 'trialing',
239    periodEnd?: string
240  ): Promise<void> {
241    const update: Record<string, unknown> = {
242      subscription_status: status,
243      updated_at: new Date().toISOString(),
244    };
245    if (periodEnd) update.subscription_period_end = periodEnd;
246  
247    const { error } = await client
248      .from('user_quotas')
249      .update(update)
250      .eq('user_id', userId);
251    if (error) throw new Error(`Failed to update subscription status: ${error.message}`);
252  }