diff --git a/package.json b/package.json index 9ae382bc..fe700d27 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "next build", "gen:geist": "ts-node -P scripts/tsconfig.json scripts/geist.ts", "gen:license": "ts-node -P scripts/tsconfig.json scripts/license.ts", + "gen:db-ts": "supabase gen types typescript --linked > ./src/supabase/database.types.ts", "lint": "tsc --noEmit && prettier -w */**/*.{ts,tsx} && next lint", "start": "next start" }, diff --git a/src/app/api/v0/check_email/checkUserInDb.ts b/src/app/api/v0/check_email/checkUserInDb.ts index 48e61995..cb53821a 100644 --- a/src/app/api/v0/check_email/checkUserInDb.ts +++ b/src/app/api/v0/check_email/checkUserInDb.ts @@ -14,14 +14,13 @@ type UserWithSub = { }; /** - * Checks the user's authorization token and retrieves user information. - * Also checks the user's subscription status and sets the rate limit headers. + * Retrieves user information from the database using an API token. * - * @param req - The NextRequest object. - * @returns A Promise that resolves to a UserWithSub object. - * @throws An error if the API token is missing or invalid. + * @param token - The API token to authenticate with + * @returns The user record if found + * @throws An error if the token is invalid */ -export async function checkUserInDB(req: NextRequest): Promise { +export async function getUserInDB(req: NextRequest): Promise> { const token = req.headers.get("Authorization") || req.headers.get("authorization"); @@ -53,20 +52,32 @@ export async function checkUserInDB(req: NextRequest): Promise { ) ); } - const user = data[0]; + return data[0]; +} + +/** + * Checks the user's authorization token and retrieves user information. + * Also checks the user's subscription status and sets the rate limit headers. + * + * @param req - The NextRequest object. + * @returns A Promise that resolves to a UserWithSub object. + * @throws An error if the API token is missing or invalid. + */ +export async function checkUserInDB(req: NextRequest): Promise { + const user = await getUserInDB(req); - const res2 = await supabaseAdmin + const res = await supabaseAdmin .from("sub_and_calls") .select("*") .eq("user_id", user.id) .order("current_period_start", { ascending: false }) .limit(1) .single(); - if (res2.error) { - throw convertPgError(res2.error); + if (res.error) { + throw convertPgError(res.error); } - const subAndCalls = res2.data; + const subAndCalls = res.data; const numberOfCalls = subAndCalls.number_of_calls || 0; // Set rate limit headers. @@ -116,7 +127,7 @@ interface SubAndCalls { * @param rateLimiterRes - The response object from rate-limiter-flexible. * @param limit - The limit per interval. */ -function getRateLimitHeaders( +export function getRateLimitHeaders( rateLimiterRes: RateLimiterRes, limit: number ): HeadersInit { diff --git a/src/app/api/v1/commercial_license_trial/route.ts b/src/app/api/v1/commercial_license_trial/route.ts index a4a362e4..61722609 100644 --- a/src/app/api/v1/commercial_license_trial/route.ts +++ b/src/app/api/v1/commercial_license_trial/route.ts @@ -1,6 +1,8 @@ import { - checkUserInDB, + getRateLimitHeaders, + getUserInDB, isEarlyResponse, + newEarlyResponse, } from "@/app/api/v0/check_email/checkUserInDb"; import { sentryException } from "@/util/sentry"; import { NextRequest } from "next/server"; @@ -8,6 +10,9 @@ import * as Sentry from "@sentry/nextjs"; import { CheckEmailOutput } from "@reacherhq/api"; import { supabaseAdmin } from "@/supabase/supabaseAdmin"; import { convertPgError } from "@/util/helpers"; +import { Tables } from "@/supabase/database.types"; +import { addDays, differenceInMilliseconds, parseISO } from "date-fns"; +import { RateLimiterRes } from "rate-limiter-flexible"; // WorkerOutput is a Result type in Rust. type WorkerOutput = { @@ -19,7 +24,7 @@ type WorkerOutput = { export async function POST(req: NextRequest): Promise { try { Sentry.setTag("rch.route", "/v1/commercial_license_trial"); - const { user } = await checkUserInDB(req); + const { user, rateLimitHeaders } = await checkUserInDB(req); Sentry.setContext("user", { supbaseUuid: user.id, }); @@ -49,7 +54,12 @@ export async function POST(req: NextRequest): Promise { throw convertPgError(error); } - return Response.json({ ok: true }); + return Response.json( + { ok: true }, + { + headers: rateLimitHeaders, + } + ); } catch (err) { if (isEarlyResponse(err)) { return err.response; @@ -66,3 +76,77 @@ export async function POST(req: NextRequest): Promise { ); } } + +/** + * Checks the user's authorization token and retrieves user information. + * Also checks the user's subscription status and sets the rate limit headers, + * but specifically for the /v1/commercial_license_trial endpoint. + * + * This is a copy of the checkUserInDB function in /v0/check_email, but tailored + * for the /v1/commercial_license_trial endpoint. + * + * @param req - The NextRequest object. + * @returns A Promise that resolves to a UserWithSub object. + * @throws An error if the API token is missing or invalid. + */ +export async function checkUserInDB(req: NextRequest): Promise<{ + user: Tables<"users">; + rateLimitHeaders: HeadersInit; +}> { + const user = await getUserInDB(req); + + const res = await supabaseAdmin + .from("commercial_license_trial") + .select("*") + .eq("user_id", user.id) + .limit(1) + .single(); + if (res.error) { + throw convertPgError(res.error); + } + + // If the user has a commercial license trial, we need to check the rate limit. + // First, we limit to 60 calls per minute. + if ((res.data.calls_last_minute || 0) >= 60) { + throw newEarlyResponse( + Response.json( + { + error: "Too many requests this minute. Please try again later.", + }, + { status: 429 } + ) + ); + } + + // Then, we limit to 1000 calls per day. + // We also add rate limit headers to the response. + const now = new Date(); + const callsInPast24h = res.data.calls_last_day || 0; + const nextReset = res.data.first_call_in_past_24h + ? addDays(parseISO(res.data.first_call_in_past_24h), 1) + : addDays(now, 1); + const msDiff = differenceInMilliseconds(nextReset, now); + const maxInPast24h = 10000; // Currently, hardcoding this to 10000. + const rateLimitHeaders = getRateLimitHeaders( + new RateLimiterRes( + maxInPast24h - callsInPast24h - 1, // -1 because we just consumed 1 email. + msDiff, + callsInPast24h, + undefined + ), + maxInPast24h + ); + + if (callsInPast24h >= maxInPast24h) { + throw newEarlyResponse( + Response.json( + { + error: "Too many requests today. Please contact amaury@reacher.email if you need more verifications for the Commercial License trial.", + }, + { status: 429, headers: rateLimitHeaders } + ) + ); + } + + return { user, rateLimitHeaders }; +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index e216eb33..f1b0e109 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -3,7 +3,6 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; export async function GET(request: Request) { - console.log("AAAAAAAAA callback route", request.url); // The `/auth/callback` route is required for the server-side auth flow implemented // by the Auth Helpers package. It exchanges an auth code for the user's session. // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange diff --git a/src/supabase/database.types.ts b/src/supabase/database.types.ts index 6f39a6ec..fab82856 100644 --- a/src/supabase/database.types.ts +++ b/src/supabase/database.types.ts @@ -6,7 +6,7 @@ export type Json = | { [key: string]: Json | undefined } | Json[]; -export interface Database { +export type Database = { public: { Tables: { bulk_emails: { @@ -64,15 +64,7 @@ export interface Database { payload?: Json; user_id?: string; }; - Relationships: [ - { - foreignKeyName: "bulk_jobs_user_id_fkey"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "users"; - referencedColumns: ["id"]; - } - ]; + Relationships: []; }; calls: { Row: { @@ -83,6 +75,7 @@ export interface Database { domain: string | null; duration: number | null; endpoint: string; + error: Json | null; id: number; is_reachable: | Database["public"]["Enums"]["is_reachable_type"] @@ -100,6 +93,7 @@ export interface Database { domain?: string | null; duration?: number | null; endpoint: string; + error?: Json | null; id?: number; is_reachable?: | Database["public"]["Enums"]["is_reachable_type"] @@ -117,6 +111,7 @@ export interface Database { domain?: string | null; duration?: number | null; endpoint?: string; + error?: Json | null; id?: number; is_reachable?: | Database["public"]["Enums"]["is_reachable_type"] @@ -133,13 +128,6 @@ export interface Database { isOneToOne: false; referencedRelation: "bulk_emails"; referencedColumns: ["id"]; - }, - { - foreignKeyName: "calls_user_id_fkey"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "users"; - referencedColumns: ["id"]; } ]; }; @@ -156,15 +144,7 @@ export interface Database { id?: string; stripe_customer_id?: string | null; }; - Relationships: [ - { - foreignKeyName: "customers_id_fkey"; - columns: ["id"]; - isOneToOne: true; - referencedRelation: "users"; - referencedColumns: ["id"]; - } - ]; + Relationships: []; }; prices: { Row: { @@ -321,13 +301,6 @@ export interface Database { isOneToOne: false; referencedRelation: "prices"; referencedColumns: ["id"]; - }, - { - foreignKeyName: "subscriptions_user_id_fkey"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "users"; - referencedColumns: ["id"]; } ]; }; @@ -359,15 +332,7 @@ export interface Database { payment_method?: Json | null; sendinblue_contact_id?: string | null; }; - Relationships: [ - { - foreignKeyName: "users_id_fkey"; - columns: ["id"]; - isOneToOne: true; - referencedRelation: "users"; - referencedColumns: ["id"]; - } - ]; + Relationships: []; }; }; Views: { @@ -384,15 +349,16 @@ export interface Database { user_id: string | null; verified: number | null; }; - Relationships: [ - { - foreignKeyName: "bulk_jobs_user_id_fkey"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "users"; - referencedColumns: ["id"]; - } - ]; + Relationships: []; + }; + commercial_license_trial: { + Row: { + calls_last_day: number | null; + calls_last_minute: number | null; + first_call_in_past_24h: string | null; + user_id: string | null; + }; + Relationships: []; }; sub_and_calls: { Row: { @@ -407,15 +373,7 @@ export interface Database { subscription_id: string | null; user_id: string | null; }; - Relationships: [ - { - foreignKeyName: "users_id_fkey"; - columns: ["user_id"]; - isOneToOne: true; - referencedRelation: "users"; - referencedColumns: ["id"]; - } - ]; + Relationships: []; }; }; Functions: { @@ -539,6 +497,7 @@ export interface Database { owner_id: string | null; path_tokens: string[] | null; updated_at: string | null; + user_metadata: Json | null; version: string | null; }; Insert: { @@ -552,6 +511,7 @@ export interface Database { owner_id?: string | null; path_tokens?: string[] | null; updated_at?: string | null; + user_metadata?: Json | null; version?: string | null; }; Update: { @@ -565,6 +525,7 @@ export interface Database { owner_id?: string | null; path_tokens?: string[] | null; updated_at?: string | null; + user_metadata?: Json | null; version?: string | null; }; Relationships: [ @@ -577,6 +538,104 @@ export interface Database { } ]; }; + s3_multipart_uploads: { + Row: { + bucket_id: string; + created_at: string; + id: string; + in_progress_size: number; + key: string; + owner_id: string | null; + upload_signature: string; + user_metadata: Json | null; + version: string; + }; + Insert: { + bucket_id: string; + created_at?: string; + id: string; + in_progress_size?: number; + key: string; + owner_id?: string | null; + upload_signature: string; + user_metadata?: Json | null; + version: string; + }; + Update: { + bucket_id?: string; + created_at?: string; + id?: string; + in_progress_size?: number; + key?: string; + owner_id?: string | null; + upload_signature?: string; + user_metadata?: Json | null; + version?: string; + }; + Relationships: [ + { + foreignKeyName: "s3_multipart_uploads_bucket_id_fkey"; + columns: ["bucket_id"]; + isOneToOne: false; + referencedRelation: "buckets"; + referencedColumns: ["id"]; + } + ]; + }; + s3_multipart_uploads_parts: { + Row: { + bucket_id: string; + created_at: string; + etag: string; + id: string; + key: string; + owner_id: string | null; + part_number: number; + size: number; + upload_id: string; + version: string; + }; + Insert: { + bucket_id: string; + created_at?: string; + etag: string; + id?: string; + key: string; + owner_id?: string | null; + part_number: number; + size?: number; + upload_id: string; + version: string; + }; + Update: { + bucket_id?: string; + created_at?: string; + etag?: string; + id?: string; + key?: string; + owner_id?: string | null; + part_number?: number; + size?: number; + upload_id?: string; + version?: string; + }; + Relationships: [ + { + foreignKeyName: "s3_multipart_uploads_parts_bucket_id_fkey"; + columns: ["bucket_id"]; + isOneToOne: false; + referencedRelation: "buckets"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "s3_multipart_uploads_parts_upload_id_fkey"; + columns: ["upload_id"]; + isOneToOne: false; + referencedRelation: "s3_multipart_uploads"; + referencedColumns: ["id"]; + } + ]; + }; }; Views: { [_ in never]: never; @@ -607,7 +666,7 @@ export interface Database { Args: { name: string; }; - Returns: unknown; + Returns: string[]; }; get_size_by_bucket: { Args: Record; @@ -616,6 +675,41 @@ export interface Database { bucket_id: string; }[]; }; + list_multipart_uploads_with_delimiter: { + Args: { + bucket_id: string; + prefix_param: string; + delimiter_param: string; + max_keys?: number; + next_key_token?: string; + next_upload_token?: string; + }; + Returns: { + key: string; + id: string; + created_at: string; + }[]; + }; + list_objects_with_delimiter: { + Args: { + bucket_id: string; + prefix_param: string; + delimiter_param: string; + max_keys?: number; + start_after?: string; + next_token?: string; + }; + Returns: { + name: string; + id: string; + metadata: Json; + updated_at: string; + }[]; + }; + operation: { + Args: Record; + Returns: string; + }; search: { Args: { prefix: string; @@ -644,11 +738,13 @@ export interface Database { [_ in never]: never; }; }; -} +}; + +type PublicSchema = Database[Extract]; export type Tables< PublicTableNameOrOptions extends - | keyof (Database["public"]["Tables"] & Database["public"]["Views"]) + | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) | { schema: keyof Database }, TableName extends PublicTableNameOrOptions extends { schema: keyof Database; @@ -663,10 +759,10 @@ export type Tables< } ? R : never - : PublicTableNameOrOptions extends keyof (Database["public"]["Tables"] & - Database["public"]["Views"]) - ? (Database["public"]["Tables"] & - Database["public"]["Views"])[PublicTableNameOrOptions] extends { + : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & + PublicSchema["Views"]) + ? (PublicSchema["Tables"] & + PublicSchema["Views"])[PublicTableNameOrOptions] extends { Row: infer R; } ? R @@ -675,7 +771,7 @@ export type Tables< export type TablesInsert< PublicTableNameOrOptions extends - | keyof Database["public"]["Tables"] + | keyof PublicSchema["Tables"] | { schema: keyof Database }, TableName extends PublicTableNameOrOptions extends { schema: keyof Database; @@ -688,8 +784,8 @@ export type TablesInsert< } ? I : never - : PublicTableNameOrOptions extends keyof Database["public"]["Tables"] - ? Database["public"]["Tables"][PublicTableNameOrOptions] extends { + : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] + ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { Insert: infer I; } ? I @@ -698,7 +794,7 @@ export type TablesInsert< export type TablesUpdate< PublicTableNameOrOptions extends - | keyof Database["public"]["Tables"] + | keyof PublicSchema["Tables"] | { schema: keyof Database }, TableName extends PublicTableNameOrOptions extends { schema: keyof Database; @@ -711,8 +807,8 @@ export type TablesUpdate< } ? U : never - : PublicTableNameOrOptions extends keyof Database["public"]["Tables"] - ? Database["public"]["Tables"][PublicTableNameOrOptions] extends { + : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] + ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { Update: infer U; } ? U @@ -721,13 +817,28 @@ export type TablesUpdate< export type Enums< PublicEnumNameOrOptions extends - | keyof Database["public"]["Enums"] + | keyof PublicSchema["Enums"] | { schema: keyof Database }, EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] : never = never > = PublicEnumNameOrOptions extends { schema: keyof Database } ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] - : PublicEnumNameOrOptions extends keyof Database["public"]["Enums"] - ? Database["public"]["Enums"][PublicEnumNameOrOptions] + : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] + ? PublicSchema["Enums"][PublicEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof PublicSchema["CompositeTypes"] + | { schema: keyof Database }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof Database; + } + ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never +> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } + ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] + ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] : never; diff --git a/src/supabase/supabaseServer.ts b/src/supabase/supabaseServer.ts index aa157ec2..360ca095 100644 --- a/src/supabase/supabaseServer.ts +++ b/src/supabase/supabaseServer.ts @@ -95,7 +95,5 @@ export async function getSubAndCalls( ); } - console.log("aaa", data[0]); - return data[0]; } diff --git a/supabase/migrations/20241214113511_commercial_license_trial.sql b/supabase/migrations/20241214113511_commercial_license_trial.sql new file mode 100644 index 00000000..97fa2ee9 --- /dev/null +++ b/supabase/migrations/20241214113511_commercial_license_trial.sql @@ -0,0 +1,13 @@ +CREATE VIEW commercial_license_trial AS +SELECT + user_id, + COUNT(*) AS calls_last_day, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '1 minute' THEN 1 END) AS calls_last_minute, + MIN(created_at) AS first_call_in_past_24h +FROM + public.calls +WHERE + endpoint = '/v1/commercial_license_trial' + AND created_at >= NOW() - INTERVAL '24 hours' +GROUP BY + user_id; \ No newline at end of file