diff --git a/apps/api/.env.example b/apps/api/.env.example index eed6bd8..c39c8fa 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -52,4 +52,7 @@ PROXY_PASSWORD= BLOCK_MEDIA= # Set this to the URL of your webhook when using the self-hosted version of FireCrawl -SELF_HOSTED_WEBHOOK_URL= \ No newline at end of file +SELF_HOSTED_WEBHOOK_URL= + +# Resend API Key for transactional emails +RESEND_API_KEY= diff --git a/apps/api/package.json b/apps/api/package.json index a955a80..b50ac09 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -90,6 +90,7 @@ "puppeteer": "^22.6.3", "rate-limiter-flexible": "^2.4.2", "redis": "^4.6.7", + "resend": "^3.2.0", "robots-parser": "^3.0.1", "scrapingbee": "^1.7.4", "stripe": "^12.2.0", diff --git a/apps/api/pnpm-lock.yaml b/apps/api/pnpm-lock.yaml index 26954a3..32a9274 100644 --- a/apps/api/pnpm-lock.yaml +++ b/apps/api/pnpm-lock.yaml @@ -146,6 +146,9 @@ dependencies: redis: specifier: ^4.6.7 version: 4.6.14 + resend: + specifier: ^3.2.0 + version: 3.2.0 robots-parser: specifier: ^3.0.1 version: 3.0.1 @@ -1471,6 +1474,10 @@ packages: - debug dev: false + /@one-ini/wasm@0.1.1: + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + dev: false + /@opentelemetry/api-logs@0.51.1: resolution: {integrity: sha512-E3skn949Pk1z2XtXu/lxf6QAZpawuTM/IUEXcAzpiUkTd73Hmvw26FiN3cJuTmkpM5hZzHwkomVdtrh/n/zzwA==} engines: {node: '>=14'} @@ -2522,6 +2529,16 @@ packages: - supports-color dev: false + /@react-email/render@0.0.12: + resolution: {integrity: sha512-S8WRv/PqECEi6x0QJBj0asnAb5GFtJaHlnByxLETLkgJjc76cxMYDH4r9wdbuJ4sjkcbpwP3LPnVzwS+aIjT7g==} + engines: {node: '>=18.0.0'} + dependencies: + html-to-text: 9.0.5 + js-beautify: 1.15.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@redis/bloom@1.2.0(@redis/client@1.5.16): resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -2571,6 +2588,13 @@ packages: '@redis/client': 1.5.16 dev: false + /@selderee/plugin-htmlparser2@0.11.0: + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + dev: false + /@sentry-internal/tracing@7.116.0: resolution: {integrity: sha512-y5ppEmoOlfr77c/HqsEXR72092qmGYS4QE5gSz5UZFn9CiinEwGfEorcg2xIrrCuU7Ry/ZU2VLz9q3xd04drRA==} engines: {node: '>=8'} @@ -3027,6 +3051,11 @@ packages: resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} + /abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dev: false + /abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3669,6 +3698,13 @@ packages: /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + /config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + dev: false + /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3990,6 +4026,17 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.6.2 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -4593,6 +4640,17 @@ packages: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true + /html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + dev: false + /htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} dependencies: @@ -4701,6 +4759,10 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: false + /ioredis@5.4.1: resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} engines: {node: '>=12.22.0'} @@ -5313,6 +5375,23 @@ packages: resolution: {integrity: sha512-qL4+1iycQjZ1fs8zk3jSRk7cg3ROBUHk7GKtiLAQLFzLPKErnILUvz5DLszSQvz3s1sTjPbywLDISVUtBY6HaA==} dev: false + /js-beautify@1.15.1: + resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} + engines: {node: '>=14'} + hasBin: true + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.4.1 + js-cookie: 3.0.5 + nopt: 7.2.1 + dev: false + + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + /js-tiktoken@1.0.12: resolution: {integrity: sha512-L7wURW1fH9Qaext0VzaUDpFGVQgjkdE3Dgsy9/+yXyGEpBKnylTd0mU0bfbNkKDlXRb6TEsZkwuflu1B8uQbJQ==} dependencies: @@ -5716,6 +5795,10 @@ packages: engines: {node: '>= 0.4.8'} dev: false + /leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + dev: false + /leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -5818,6 +5901,13 @@ packages: resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} dev: false + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + /lop@0.4.1: resolution: {integrity: sha512-9xyho9why2A2tzm5aIcMWKvzqKsnxrf9B5I+8O30olh6lQU8PH978LqZoI4++37RBgS1Em5i54v1TFs/3wnmXQ==} dependencies: @@ -5961,6 +6051,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimatch@9.0.4: resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} engines: {node: '>=16 || 14 >=14.17'} @@ -6253,6 +6350,14 @@ packages: undefsafe: 2.0.5 dev: true + /nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + dependencies: + abbrev: 2.0.0 + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -6465,6 +6570,13 @@ packages: entities: 4.5.0 dev: false + /parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + dev: false + /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6505,6 +6617,10 @@ packages: - supports-color dev: false + /peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + dev: false + /pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} dev: false @@ -6700,6 +6816,10 @@ packages: sisteransi: 1.0.5 dev: true + /proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + dev: false + /protobufjs@7.3.0: resolution: {integrity: sha512-YWD03n3shzV9ImZRX3ccbjqLxj7NokGN0V/ESiBV5xWqrommYHYiihuIyavq03pWSGqlyvYUFmfoMKd+1rPA/g==} engines: {node: '>=12.0.0'} @@ -6831,10 +6951,27 @@ packages: iconv-lite: 0.4.24 unpipe: 1.0.0 + /react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.2 + dev: false + /react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} dev: true + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + /read-cmd-shim@4.0.0: resolution: {integrity: sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -6933,6 +7070,13 @@ packages: - supports-color dev: false + /resend@3.2.0: + resolution: {integrity: sha512-lDHhexiFYPoLXy7zRlJ8D5eKxoXy6Tr9/elN3+Vv7PkUoYuSSD1fpiIfa/JYXEWyiyN2UczkCTLpkT8dDPJ4Pg==} + engines: {node: '>=18'} + dependencies: + '@react-email/render': 0.0.12 + dev: false + /resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -7020,6 +7164,12 @@ packages: sanitize-html: 2.13.0 dev: false + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + dependencies: + loose-envify: 1.4.0 + dev: false + /scrapingbee@1.7.4: resolution: {integrity: sha512-cTo+mfLi+T3mSeCHIefVZpjWEX2O70SkmCoWj9ypsnIFqBI2GmljdHYXt8yoT6D/YKjI0rHE7YH9iVRdhyoMmQ==} dependencies: @@ -7030,6 +7180,12 @@ packages: - debug dev: false + /selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + dependencies: + parseley: 0.12.1 + dev: false + /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index b7eecd8..2fdbf4e 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -1,10 +1,11 @@ import { parseApi } from "../../src/lib/parseApi"; import { getRateLimiter, } from "../../src/services/rate-limiter"; -import { AuthResponse, RateLimiterMode } from "../../src/types"; +import { AuthResponse, NotificationType, RateLimiterMode } from "../../src/types"; import { supabase_service } from "../../src/services/supabase"; import { withAuth } from "../../src/lib/withAuth"; import { RateLimiterRedis } from "rate-limiter-flexible"; import { setTraceAttributes } from '@hyperdx/node-opentelemetry'; +import { sendNotification } from "../services/notification/email_notification"; export async function authenticateUser(req, res, mode?: RateLimiterMode): Promise { return withAuth(supaAuthenticateUser)(req, res, mode); @@ -52,8 +53,11 @@ export async function supaAuthenticateUser( let subscriptionData: { team_id: string, plan: string } | null = null; let normalizedApi: string; + let team_id: string; + if (token == "this_is_just_a_preview_token") { rateLimiter = getRateLimiter(RateLimiterMode.Preview, token); + team_id = "preview"; } else { normalizedApi = parseApi(token); @@ -90,7 +94,9 @@ export async function supaAuthenticateUser( status: 401, }; } - const team_id = data[0].team_id; + const internal_team_id = data[0].team_id; + team_id = internal_team_id; + const plan = getPlanByPriceId(data[0].price_id); // HyperDX Logging setTrace(team_id, normalizedApi); @@ -124,12 +130,20 @@ export async function supaAuthenticateUser( } } + const team_endpoint_token = team_id; + try { - await rateLimiter.consume(iptoken); + await rateLimiter.consume(team_endpoint_token); } catch (rateLimiterRes) { console.error(rateLimiterRes); const secs = Math.round(rateLimiterRes.msBeforeNext / 1000) || 1; const retryDate = new Date(Date.now() + rateLimiterRes.msBeforeNext); + + // We can only send a rate limit email every 7 days, send notification already has the date in between checking + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 7); + await sendNotification(team_id, NotificationType.RATE_LIMIT_REACHED, startDate.toISOString(), endDate.toISOString()); return { success: false, error: `Rate limit exceeded. Consumed points: ${rateLimiterRes.consumedPoints}, Remaining points: ${rateLimiterRes.remainingPoints}. Upgrade your plan at https://firecrawl.dev/pricing for increased rate limits or please retry after ${secs}s, resets at ${retryDate}`, diff --git a/apps/api/src/services/billing/credit_billing.ts b/apps/api/src/services/billing/credit_billing.ts index c0230d4..6f06fa1 100644 --- a/apps/api/src/services/billing/credit_billing.ts +++ b/apps/api/src/services/billing/credit_billing.ts @@ -1,4 +1,6 @@ +import { NotificationType } from "../../types"; import { withAuth } from "../../lib/withAuth"; +import { sendNotification } from "../notification/email_notification"; import { supabase_service } from "../supabase"; const FREE_CREDITS = 500; @@ -34,7 +36,10 @@ export async function supaBillTeam(team_id: string, credits: number) { let couponCredits = 0; if (coupons && coupons.length > 0) { - couponCredits = coupons.reduce((total, coupon) => total + coupon.credits, 0); + couponCredits = coupons.reduce( + (total, coupon) => total + coupon.credits, + 0 + ); } let sortedCoupons = coupons.sort((a, b) => b.credits - a.credits); @@ -53,28 +58,27 @@ export async function supaBillTeam(team_id: string, credits: number) { usedCredits = usedCredits - sortedCoupons[0].credits; // update coupon credits await supabase_service - .from("coupons") - .update({ - credits: 0 - }) - .eq("id", sortedCoupons[0].id); + .from("coupons") + .update({ + credits: 0, + }) + .eq("id", sortedCoupons[0].id); sortedCoupons.shift(); - } else { // update coupon credits await supabase_service - .from("coupons") - .update({ - credits: sortedCoupons[0].credits - usedCredits - }) - .eq("id", sortedCoupons[0].id); + .from("coupons") + .update({ + credits: sortedCoupons[0].credits - usedCredits, + }) + .eq("id", sortedCoupons[0].id); usedCredits = 0; } } return await createCreditUsage({ team_id, credits: 0 }); - // not enough coupon credits and no subscription + // not enough coupon credits and no subscription } else { // update coupon credits const usedCredits = credits - couponCredits; @@ -82,7 +86,7 @@ export async function supaBillTeam(team_id: string, credits: number) { await supabase_service .from("coupons") .update({ - credits: 0 + credits: 0, }) .eq("id", sortedCoupons[i].id); } @@ -90,7 +94,7 @@ export async function supaBillTeam(team_id: string, credits: number) { return await createCreditUsage({ team_id, credits: usedCredits }); } } - + // with subscription // using coupon + subscription credits: if (credits > couponCredits) { @@ -99,14 +103,18 @@ export async function supaBillTeam(team_id: string, credits: number) { await supabase_service .from("coupons") .update({ - credits: 0 + credits: 0, }) .eq("id", sortedCoupons[i].id); } const usedCredits = credits - couponCredits; - return await createCreditUsage({ team_id, subscription_id: subscription.id, credits: usedCredits }); - - } else { // using only coupon credits + return await createCreditUsage({ + team_id, + subscription_id: subscription.id, + credits: usedCredits, + }); + } else { + // using only coupon credits let usedCredits = credits; while (usedCredits > 0) { // update coupons @@ -114,26 +122,29 @@ export async function supaBillTeam(team_id: string, credits: number) { usedCredits = usedCredits - sortedCoupons[0].credits; // update coupon credits await supabase_service - .from("coupons") - .update({ - credits: 0 - }) - .eq("id", sortedCoupons[0].id); + .from("coupons") + .update({ + credits: 0, + }) + .eq("id", sortedCoupons[0].id); sortedCoupons.shift(); - } else { // update coupon credits await supabase_service - .from("coupons") - .update({ - credits: sortedCoupons[0].credits - usedCredits - }) - .eq("id", sortedCoupons[0].id); + .from("coupons") + .update({ + credits: sortedCoupons[0].credits - usedCredits, + }) + .eq("id", sortedCoupons[0].id); usedCredits = 0; } } - return await createCreditUsage({ team_id, subscription_id: subscription.id, credits: 0 }); + return await createCreditUsage({ + team_id, + subscription_id: subscription.id, + credits: 0, + }); } } @@ -142,7 +153,11 @@ export async function supaBillTeam(team_id: string, credits: number) { return await createCreditUsage({ team_id, credits }); } - return await createCreditUsage({ team_id, subscription_id: subscription.id, credits }); + return await createCreditUsage({ + team_id, + subscription_id: subscription.id, + credits, + }); } export async function checkTeamCredits(team_id: string, credits: number) { @@ -155,12 +170,13 @@ export async function supaCheckTeamCredits(team_id: string, credits: number) { } // Retrieve the team's active subscription - const { data: subscription, error: subscriptionError } = await supabase_service - .from("subscriptions") - .select("id, price_id, current_period_start, current_period_end") - .eq("team_id", team_id) - .eq("status", "active") - .single(); + const { data: subscription, error: subscriptionError } = + await supabase_service + .from("subscriptions") + .select("id, price_id, current_period_start, current_period_end") + .eq("team_id", team_id) + .eq("status", "active") + .single(); // Check for available coupons const { data: coupons } = await supabase_service @@ -171,7 +187,10 @@ export async function supaCheckTeamCredits(team_id: string, credits: number) { let couponCredits = 0; if (coupons && coupons.length > 0) { - couponCredits = coupons.reduce((total, coupon) => total + coupon.credits, 0); + couponCredits = coupons.reduce( + (total, coupon) => total + coupon.credits, + 0 + ); } // Free credits, no coupons @@ -180,19 +199,17 @@ export async function supaCheckTeamCredits(team_id: string, credits: number) { if (couponCredits >= credits) { return { success: true, message: "Sufficient credits available" }; } - + const { data: creditUsages, error: creditUsageError } = await supabase_service .from("credit_usage") .select("credits_used") .is("subscription_id", null) .eq("team_id", team_id); - // .gte("created_at", subscription.current_period_start) - // .lte("created_at", subscription.current_period_end); if (creditUsageError) { throw new Error( - `Failed to retrieve credit usage for subscription_id: ${subscription.id}` + `Failed to retrieve credit usage for team_id: ${team_id}` ); } @@ -202,8 +219,32 @@ export async function supaCheckTeamCredits(team_id: string, credits: number) { ); console.log("totalCreditsUsed", totalCreditsUsed); + + const end = new Date(); + end.setDate(end.getDate() + 30); + // check if usage is within 80% of the limit + const creditLimit = FREE_CREDITS; + const creditUsagePercentage = (totalCreditsUsed + credits) / creditLimit; + + if (creditUsagePercentage >= 0.8) { + await sendNotification( + team_id, + NotificationType.APPROACHING_LIMIT, + new Date().toISOString(), + end.toISOString() + ); + } + // 5. Compare the total credits used with the credits allowed by the plan. if (totalCreditsUsed + credits > FREE_CREDITS) { + // Send email notification for insufficient credits + + await sendNotification( + team_id, + NotificationType.LIMIT_REACHED, + new Date().toISOString(), + end.toISOString() + ); return { success: false, message: "Insufficient credits, please upgrade!", @@ -214,25 +255,24 @@ export async function supaCheckTeamCredits(team_id: string, credits: number) { let totalCreditsUsed = 0; try { - const { data: creditUsages, error: creditUsageError } = await supabase_service - .rpc("get_credit_usage_2", { - sub_id: subscription.id, - start_time: subscription.current_period_start, - end_time: subscription.current_period_end - }); + const { data: creditUsages, error: creditUsageError } = + await supabase_service.rpc("get_credit_usage_2", { + sub_id: subscription.id, + start_time: subscription.current_period_start, + end_time: subscription.current_period_end, + }); if (creditUsageError) { console.error("Error calculating credit usage:", creditUsageError); } if (creditUsages && creditUsages.length > 0) { - totalCreditsUsed = creditUsages[0].total_credits_used; - // console.log("Total Credits Used:", totalCreditsUsed); + totalCreditsUsed = creditUsages[0].total_credits_used; } } catch (error) { console.error("Error calculating credit usage:", error); - } + // Adjust total credits used by subtracting coupon value const adjustedCreditsUsed = Math.max(0, totalCreditsUsed - couponCredits); @@ -244,12 +284,31 @@ export async function supaCheckTeamCredits(team_id: string, credits: number) { .single(); if (priceError) { - throw new Error(`Failed to retrieve price for price_id: ${subscription.price_id}`); + throw new Error( + `Failed to retrieve price for price_id: ${subscription.price_id}` + ); } + const creditLimit = price.credits; + const creditUsagePercentage = (adjustedCreditsUsed + credits) / creditLimit; + // Compare the adjusted total credits used with the credits allowed by the plan if (adjustedCreditsUsed + credits > price.credits) { + await sendNotification( + team_id, + NotificationType.LIMIT_REACHED, + subscription.current_period_start, + subscription.current_period_end + ); return { success: false, message: "Insufficient credits, please upgrade!" }; + } else if (creditUsagePercentage >= 0.8) { + // Send email notification for approaching credit limit + await sendNotification( + team_id, + NotificationType.APPROACHING_LIMIT, + subscription.current_period_start, + subscription.current_period_end + ); } return { success: true, message: "Sufficient credits available" }; @@ -275,7 +334,10 @@ export async function countCreditsAndRemainingForCurrentBillingPeriod( let couponCredits = 0; if (coupons && coupons.length > 0) { - couponCredits = coupons.reduce((total, coupon) => total + coupon.credits, 0); + couponCredits = coupons.reduce( + (total, coupon) => total + coupon.credits, + 0 + ); } if (subscriptionError || !subscription) { @@ -288,7 +350,9 @@ export async function countCreditsAndRemainingForCurrentBillingPeriod( .eq("team_id", team_id); if (creditUsageError || !creditUsages) { - throw new Error(`Failed to retrieve credit usage for team_id: ${team_id}`); + throw new Error( + `Failed to retrieve credit usage for team_id: ${team_id}` + ); } const totalCreditsUsed = creditUsages.reduce( @@ -297,7 +361,11 @@ export async function countCreditsAndRemainingForCurrentBillingPeriod( ); const remainingCredits = FREE_CREDITS + couponCredits - totalCreditsUsed; - return { totalCreditsUsed: totalCreditsUsed, remainingCredits, totalCredits: FREE_CREDITS + couponCredits }; + return { + totalCreditsUsed: totalCreditsUsed, + remainingCredits, + totalCredits: FREE_CREDITS + couponCredits, + }; } const { data: creditUsages, error: creditUsageError } = await supabase_service @@ -308,10 +376,15 @@ export async function countCreditsAndRemainingForCurrentBillingPeriod( .lte("created_at", subscription.current_period_end); if (creditUsageError || !creditUsages) { - throw new Error(`Failed to retrieve credit usage for subscription_id: ${subscription.id}`); + throw new Error( + `Failed to retrieve credit usage for subscription_id: ${subscription.id}` + ); } - const totalCreditsUsed = creditUsages.reduce((acc, usage) => acc + usage.credits_used, 0); + const totalCreditsUsed = creditUsages.reduce( + (acc, usage) => acc + usage.credits_used, + 0 + ); const { data: price, error: priceError } = await supabase_service .from("prices") @@ -320,7 +393,9 @@ export async function countCreditsAndRemainingForCurrentBillingPeriod( .single(); if (priceError || !price) { - throw new Error(`Failed to retrieve price for price_id: ${subscription.price_id}`); + throw new Error( + `Failed to retrieve price for price_id: ${subscription.price_id}` + ); } const remainingCredits = price.credits + couponCredits - totalCreditsUsed; @@ -328,11 +403,19 @@ export async function countCreditsAndRemainingForCurrentBillingPeriod( return { totalCreditsUsed, remainingCredits, - totalCredits: price.credits + totalCredits: price.credits, }; } -async function createCreditUsage({ team_id, subscription_id, credits }: { team_id: string, subscription_id?: string, credits: number }) { +async function createCreditUsage({ + team_id, + subscription_id, + credits, +}: { + team_id: string; + subscription_id?: string; + credits: number; +}) { const { data: credit_usage } = await supabase_service .from("credit_usage") .insert([ @@ -346,4 +429,4 @@ async function createCreditUsage({ team_id, subscription_id, credits }: { team_i .select(); return { success: true, credit_usage }; -} \ No newline at end of file +} diff --git a/apps/api/src/services/notification/email_notification.ts b/apps/api/src/services/notification/email_notification.ts new file mode 100644 index 0000000..7ae2f53 --- /dev/null +++ b/apps/api/src/services/notification/email_notification.ts @@ -0,0 +1,120 @@ +import { supabase_service } from "../supabase"; +import { withAuth } from "../../lib/withAuth"; +import { Resend } from "resend"; +import { NotificationType } from "../../types"; + + + + +const emailTemplates: Record< + NotificationType, + { subject: string; html: string } +> = { + [NotificationType.APPROACHING_LIMIT]: { + subject: "You've used 80% of your credit limit - Firecrawl", + html: "Hey there,

You are approaching your credit limit for this billing period. Your usage right now is around 80% of your total credit limit. Consider upgrading your plan to avoid hitting the limit. Check out our pricing page for more info.


Thanks,
Firecrawl Team
", + }, + [NotificationType.LIMIT_REACHED]: { + subject: "Credit Limit Reached! Take action now to resume usage - Firecrawl", + html: "Hey there,

You have reached your credit limit for this billing period. To resume usage, please upgrade your plan. Check out our pricing page for more info.


Thanks,
Firecrawl Team
", + }, + [NotificationType.RATE_LIMIT_REACHED]: { + subject: "Rate Limit Reached - Firecrawl", + html: "Hey there,

You've hit your Firecrawl rate limit! Take a breather and try again in a few moments. If you need more higher rate limits, consider upgrading your plan. Check out our pricing page for more info.

If you have any questions, feel free to reach out to us at hello@firecrawl.com


Thanks,
Firecrawl Team
Ps. this email is only sent once every 7 days if you reach the limit.", + }, +}; + +export async function sendNotification( + team_id: string, + notificationType: NotificationType, + startDateString: string, + endDateString: string +) { + return withAuth(sendNotificationInternal)( + team_id, + notificationType, + startDateString, + endDateString + ); +} + +async function sendEmailNotification( + email: string, + notificationType: NotificationType +) { +const resend = new Resend(process.env.RESEND_API_KEY); + + try { + const { data, error } = await resend.emails.send({ + from: "Firecrawl ", + to: [email], + reply_to: "hello@firecrawl.com", + subject: emailTemplates[notificationType].subject, + html: emailTemplates[notificationType].html, + }); + + if (error) { + console.error("Error sending email: ", error); + return { success: false }; + } + } catch (error) { + console.error("Error sending email (2): ", error); + return { success: false }; + } +} + +export async function sendNotificationInternal( + team_id: string, + notificationType: NotificationType, + startDateString: string, + endDateString: string +): Promise<{ success: boolean }> { + const { data, error } = await supabase_service + .from("user_notifications") + .select("*") + .eq("team_id", team_id) + .eq("notification_type", notificationType) + .gte("sent_date", startDateString) + .lte("sent_date", endDateString); + + if (error) { + console.error("Error fetching notifications: ", error); + return { success: false }; + } + + if (data.length !== 0) { + return { success: false }; + } else { + // get the emails from the user with the team_id + const { data: emails, error: emailsError } = await supabase_service + .from("users") + .select("email") + .eq("team_id", team_id); + + if (emailsError) { + console.error("Error fetching emails: ", emailsError); + return { success: false }; + } + + for (const email of emails) { + await sendEmailNotification(email.email, notificationType); + } + + const { error: insertError } = await supabase_service + .from("user_notifications") + .insert([ + { + team_id: team_id, + notification_type: notificationType, + sent_date: new Date().toISOString(), + }, + ]); + + if (insertError) { + console.error("Error inserting notification record: ", insertError); + return { success: false }; + } + + return { success: true }; + } +} diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 8dfbb0c..20fb527 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -61,3 +61,8 @@ export interface AuthResponse { } +export enum NotificationType { + APPROACHING_LIMIT = "approachingLimit", + LIMIT_REACHED = "limitReached", + RATE_LIMIT_REACHED = "rateLimitReached", +} \ No newline at end of file