diff --git a/.github/workflows/send-limit-email-alerts.yml b/.github/workflows/check-and-report-chats-usage.yml similarity index 85% rename from .github/workflows/send-limit-email-alerts.yml rename to .github/workflows/check-and-report-chats-usage.yml index 8118d3218d7..e4a04f37f72 100644 --- a/.github/workflows/send-limit-email-alerts.yml +++ b/.github/workflows/check-and-report-chats-usage.yml @@ -1,4 +1,4 @@ -name: Send chats limit alert emails +name: Check and report chats usage on: schedule: @@ -22,8 +22,9 @@ jobs: SMTP_HOST: '${{ secrets.SMTP_HOST }}' SMTP_PORT: '${{ secrets.SMTP_PORT }}' NEXT_PUBLIC_SMTP_FROM: '${{ secrets.NEXT_PUBLIC_SMTP_FROM }}' + STRIPE_SECRET_KEY: '${{ secrets.STRIPE_SECRET_KEY }}' steps: - uses: actions/checkout@v2 - uses: pnpm/action-setup@v2.2.2 - run: pnpm i --frozen-lockfile - - run: pnpm turbo run sendAlertEmails + - run: pnpm turbo run checkAndReportChatsUsage diff --git a/.github/workflows/send-total-results-digest.yml b/.github/workflows/send-total-results-digest.yml deleted file mode 100644 index 7c262d298b9..00000000000 --- a/.github/workflows/send-total-results-digest.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Send total results daily digest - -on: - schedule: - - cron: '0 5 * * *' - -jobs: - send: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./packages/scripts - env: - DATABASE_URL: '${{ secrets.DATABASE_URL }}' - ENCRYPTION_SECRET: '${{ secrets.ENCRYPTION_SECRET }}' - NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_VIEWER_URL: 'http://localhost:3001' - TELEMETRY_WEBHOOK_URL: '${{ secrets.TELEMETRY_WEBHOOK_URL }}' - TELEMETRY_WEBHOOK_BEARER_TOKEN: '${{ secrets.TELEMETRY_WEBHOOK_BEARER_TOKEN }}' - steps: - - uses: actions/checkout@v2 - - uses: pnpm/action-setup@v2.2.2 - - run: pnpm i --frozen-lockfile - - run: pnpm turbo run telemetry:sendTotalResultsDigest diff --git a/packages/scripts/sendTotalResultsDigest.ts b/packages/scripts/checkAndReportChatsUsage.ts similarity index 71% rename from packages/scripts/sendTotalResultsDigest.ts rename to packages/scripts/checkAndReportChatsUsage.ts index 9fbc18c049a..342059a8d0e 100644 --- a/packages/scripts/sendTotalResultsDigest.ts +++ b/packages/scripts/checkAndReportChatsUsage.ts @@ -6,23 +6,27 @@ import { } from '@typebot.io/prisma' import { isDefined, isEmpty } from '@typebot.io/lib' import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit' +import { getUsage } from '@typebot.io/lib/api/getUsage' import { promptAndSetEnvironment } from './utils' +import { Workspace } from '@typebot.io/schemas' +import { sendAlmostReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/AlmostReachedChatsLimitEmail' import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry' import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent' -import { Workspace } from '@typebot.io/schemas' -import { Stripe } from 'stripe' +import Stripe from 'stripe' import { createId } from '@paralleldrive/cuid2' const prisma = new PrismaClient() +const LIMIT_EMAIL_TRIGGER_PERCENT = 0.75 type WorkspaceForDigest = Pick< Workspace, | 'id' | 'plan' + | 'name' | 'customChatsLimit' - | 'customStorageLimit' - | 'additionalStorageIndex' | 'isQuarantined' + | 'chatsLimitFirstEmailSentAt' + | 'chatsLimitSecondEmailSentAt' > & { members: (Pick & { user: { id: string; email: string | null } @@ -55,14 +59,14 @@ type ResultWithWorkspace = { isFirstOfKind: true | undefined } -export const sendTotalResultsDigest = async () => { +export const checkAndReportChatsUsage = async () => { await promptAndSetEnvironment('production') - console.log("Generating total results yesterday's digest...") - const todayMidnight = new Date() - todayMidnight.setUTCHours(0, 0, 0, 0) - const yesterday = new Date(todayMidnight) - yesterday.setDate(yesterday.getDate() - 1) + console.log('Get collected results from the last hour...') + + const zeroedMinutesHour = new Date() + zeroedMinutesHour.setUTCMinutes(0, 0, 0) + const hourAgo = new Date(zeroedMinutesHour.getTime() - 1000 * 60 * 60) const results = await prisma.result.groupBy({ by: ['typebotId'], @@ -72,8 +76,8 @@ export const sendTotalResultsDigest = async () => { where: { hasStarted: true, createdAt: { - gte: yesterday, - lt: todayMidnight, + lt: zeroedMinutesHour, + gte: hourAgo, }, }, }) @@ -82,7 +86,7 @@ export const sendTotalResultsDigest = async () => { `Found ${results.reduce( (total, result) => total + result._count._all, 0 - )} results collected yesterday.` + )} results collected for the last hour.` ) const workspaces = await prisma.workspace.findMany({ @@ -95,6 +99,7 @@ export const sendTotalResultsDigest = async () => { }, select: { id: true, + name: true, typebots: { select: { id: true } }, members: { select: { user: { select: { id: true, email: true } }, role: true }, @@ -104,6 +109,8 @@ export const sendTotalResultsDigest = async () => { customStorageLimit: true, plan: true, isQuarantined: true, + chatsLimitFirstEmailSentAt: true, + chatsLimitSecondEmailSentAt: true, stripeId: true, }, }) @@ -124,20 +131,18 @@ export const sendTotalResultsDigest = async () => { isFirstOfKind: memberIndex === 0 ? (true as const) : undefined, })) }) - .filter(isDefined) satisfies ResultWithWorkspace[] - - console.log('Reporting usage to Stripe...') + .filter(isDefined) - await reportUsageToStripe(resultsWithWorkspaces) - - console.log('Computing workspaces limits...') + console.log('Check limits...') - const workspaceLimitReachedEvents = await sendAlertIfLimitReached( + const events = await sendAlertIfLimitReached( resultsWithWorkspaces .filter((result) => result.isFirstOfKind) .map((result) => result.workspace) ) + await reportUsageToStripe(resultsWithWorkspaces) + const newResultsCollectedEvents = resultsWithWorkspaces.map( (result) => ({ @@ -152,16 +157,11 @@ export const sendTotalResultsDigest = async () => { } satisfies TelemetryEvent) ) - await sendTelemetryEvents( - workspaceLimitReachedEvents.concat(newResultsCollectedEvents) - ) - - console.log( - `Sent ${workspaceLimitReachedEvents.length} workspace limit reached events.` - ) console.log( - `Sent ${newResultsCollectedEvents.length} new results collected events.` + `Send ${newResultsCollectedEvents.length} new results events and ${events.length} auto quarantine events...` ) + + await sendTelemetryEvents(events.concat(newResultsCollectedEvents)) } const sendAlertIfLimitReached = async ( @@ -173,16 +173,50 @@ const sendAlertIfLimitReached = async ( if (taggedWorkspaces.includes(workspace.id) || workspace.isQuarantined) continue taggedWorkspaces.push(workspace.id) - const { totalChatsUsed } = await getUsage(workspace.id) + const { totalChatsUsed } = await getUsage(prisma)(workspace.id) const chatsLimit = getChatsLimit(workspace) - if (chatsLimit > 0 && totalChatsUsed >= chatsLimit) { + if ( + chatsLimit > 0 && + totalChatsUsed >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT && + totalChatsUsed < chatsLimit && + !workspace.chatsLimitFirstEmailSentAt + ) { + const to = workspace.members + .filter((member) => member.role === WorkspaceRole.ADMIN) + .map((member) => member.user.email) + .filter(isDefined) + console.log( + `Send almost reached chats limit email to ${to.join(', ')}...` + ) + try { + await sendAlmostReachedChatsLimitEmail({ + to, + usagePercent: Math.round((totalChatsUsed / chatsLimit) * 100), + chatsLimit, + workspaceName: workspace.name, + }) + await prisma.workspace.updateMany({ + where: { id: workspace.id }, + data: { chatsLimitFirstEmailSentAt: new Date() }, + }) + } catch (err) { + console.error(err) + } + } + + if (totalChatsUsed > chatsLimit * 1.5 && workspace.plan === Plan.FREE) { + console.log(`Automatically quarantine workspace ${workspace.id}...`) + await prisma.workspace.updateMany({ + where: { id: workspace.id }, + data: { isQuarantined: true }, + }) events.push( ...workspace.members .filter((member) => member.role === WorkspaceRole.ADMIN) .map( (member) => ({ - name: 'Workspace limit reached', + name: 'Workspace automatically quarantined', userId: member.user.id, workspaceId: workspace.id, data: { @@ -192,7 +226,6 @@ const sendAlertIfLimitReached = async ( } satisfies TelemetryEvent) ) ) - continue } } return events @@ -261,35 +294,4 @@ const reportUsageToStripe = async ( } } -const getUsage = async (workspaceId: string) => { - const now = new Date() - const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) - const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1) - const typebots = await prisma.typebot.findMany({ - where: { - workspace: { - id: workspaceId, - }, - }, - select: { id: true }, - }) - - const [totalChatsUsed] = await Promise.all([ - prisma.result.count({ - where: { - typebotId: { in: typebots.map((typebot) => typebot.id) }, - hasStarted: true, - createdAt: { - gte: firstDayOfMonth, - lt: firstDayOfNextMonth, - }, - }, - }), - ]) - - return { - totalChatsUsed, - } -} - -sendTotalResultsDigest().then() +checkAndReportChatsUsage().then() diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 12ea10d48dd..1e0de6760f9 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -13,7 +13,7 @@ "db:bulkUpdate": "tsx bulkUpdate.ts", "db:fixTypebots": "tsx fixTypebots.ts", "telemetry:sendTotalResultsDigest": "tsx sendTotalResultsDigest.ts", - "sendAlertEmails": "tsx sendAlertEmails.ts", + "checkAndReportChatsUsage": "tsx checkAndReportChatsUsage.ts", "inspectUser": "tsx inspectUser.ts", "checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts", "createChatsPrices": "tsx createChatsPrices.ts" diff --git a/packages/scripts/sendAlertEmails.ts b/packages/scripts/sendAlertEmails.ts deleted file mode 100644 index 36494608cc8..00000000000 --- a/packages/scripts/sendAlertEmails.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { - MemberInWorkspace, - Plan, - PrismaClient, - WorkspaceRole, -} from '@typebot.io/prisma' -import { isDefined } from '@typebot.io/lib' -import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit' -import { getUsage } from '@typebot.io/lib/api/getUsage' -import { promptAndSetEnvironment } from './utils' -import { Workspace } from '@typebot.io/schemas' -import { sendAlmostReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/AlmostReachedChatsLimitEmail' -import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry' -import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent' - -const prisma = new PrismaClient() -const LIMIT_EMAIL_TRIGGER_PERCENT = 0.75 - -type WorkspaceForDigest = Pick< - Workspace, - | 'id' - | 'plan' - | 'name' - | 'customChatsLimit' - | 'isQuarantined' - | 'chatsLimitFirstEmailSentAt' - | 'chatsLimitSecondEmailSentAt' -> & { - members: (Pick & { - user: { id: string; email: string | null } - })[] -} - -export const sendTotalResultsDigest = async () => { - await promptAndSetEnvironment('production') - - console.log('Get collected results from the last hour...') - - const hourAgo = new Date(Date.now() - 1000 * 60 * 60) - - const results = await prisma.result.groupBy({ - by: ['typebotId'], - _count: { - _all: true, - }, - where: { - hasStarted: true, - createdAt: { - gte: hourAgo, - }, - }, - }) - - console.log( - `Found ${results.reduce( - (total, result) => total + result._count._all, - 0 - )} results collected for the last hour.` - ) - - const workspaces = await prisma.workspace.findMany({ - where: { - typebots: { - some: { - id: { in: results.map((result) => result.typebotId) }, - }, - }, - }, - select: { - id: true, - name: true, - typebots: { select: { id: true } }, - members: { - select: { user: { select: { id: true, email: true } }, role: true }, - }, - additionalStorageIndex: true, - customChatsLimit: true, - customStorageLimit: true, - plan: true, - isQuarantined: true, - chatsLimitFirstEmailSentAt: true, - chatsLimitSecondEmailSentAt: true, - }, - }) - - const resultsWithWorkspaces = results - .flatMap((result) => { - const workspace = workspaces.find((workspace) => - workspace.typebots.some((typebot) => typebot.id === result.typebotId) - ) - if (!workspace) return - return workspace.members - .filter((member) => member.role !== WorkspaceRole.GUEST) - .map((member, memberIndex) => ({ - userId: member.user.id, - workspace: workspace, - typebotId: result.typebotId, - totalResultsYesterday: result._count._all, - isFirstOfKind: memberIndex === 0 ? (true as const) : undefined, - })) - }) - .filter(isDefined) - - console.log('Check limits...') - - const events = await sendAlertIfLimitReached( - resultsWithWorkspaces - .filter((result) => result.isFirstOfKind) - .map((result) => result.workspace) - ) - - console.log(`Send ${events.length} auto quarantine events...`) - - await sendTelemetryEvents(events) -} - -const sendAlertIfLimitReached = async ( - workspaces: WorkspaceForDigest[] -): Promise => { - const events: TelemetryEvent[] = [] - const taggedWorkspaces: string[] = [] - for (const workspace of workspaces) { - if (taggedWorkspaces.includes(workspace.id) || workspace.isQuarantined) - continue - taggedWorkspaces.push(workspace.id) - const { totalChatsUsed } = await getUsage(prisma)(workspace.id) - const chatsLimit = getChatsLimit(workspace) - if ( - chatsLimit > 0 && - totalChatsUsed >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT && - totalChatsUsed < chatsLimit && - !workspace.chatsLimitFirstEmailSentAt - ) { - const to = workspace.members - .filter((member) => member.role === WorkspaceRole.ADMIN) - .map((member) => member.user.email) - .filter(isDefined) - console.log( - `Send almost reached chats limit email to ${to.join(', ')}...` - ) - try { - await sendAlmostReachedChatsLimitEmail({ - to, - usagePercent: Math.round((totalChatsUsed / chatsLimit) * 100), - chatsLimit, - workspaceName: workspace.name, - }) - await prisma.workspace.updateMany({ - where: { id: workspace.id }, - data: { chatsLimitFirstEmailSentAt: new Date() }, - }) - } catch (err) { - console.error(err) - } - } - - if (totalChatsUsed > chatsLimit * 1.5 && workspace.plan === Plan.FREE) { - console.log(`Automatically quarantine workspace ${workspace.id}...`) - await prisma.workspace.updateMany({ - where: { id: workspace.id }, - data: { isQuarantined: true }, - }) - events.push( - ...workspace.members - .filter((member) => member.role === WorkspaceRole.ADMIN) - .map( - (member) => - ({ - name: 'Workspace automatically quarantined', - userId: member.user.id, - workspaceId: workspace.id, - data: { - totalChatsUsed, - chatsLimit, - }, - } satisfies TelemetryEvent) - ) - ) - } - } - return events -} - -sendTotalResultsDigest().then()