feat: add cronjob to check workspace limit which will pause workspace
This commit is contained in:
parent
1096e9ca9a
commit
31ad64cd95
@ -543,6 +543,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.8
|
specifier: ^6.9.8
|
||||||
version: 6.9.8
|
version: 6.9.8
|
||||||
|
p-map:
|
||||||
|
specifier: 4.0.0
|
||||||
|
version: 4.0.0
|
||||||
passport:
|
passport:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.0
|
version: 0.7.0
|
||||||
@ -661,9 +664,6 @@ importers:
|
|||||||
execa:
|
execa:
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.1.1
|
version: 5.1.1
|
||||||
p-map:
|
|
||||||
specifier: 4.0.0
|
|
||||||
version: 4.0.0
|
|
||||||
prisma:
|
prisma:
|
||||||
specifier: 5.14.0
|
specifier: 5.14.0
|
||||||
version: 5.14.0
|
version: 5.14.0
|
||||||
@ -2341,10 +2341,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-gM/FdNsK3BlrD6JRrhmiyqBXQsCpzSUdKSoZwJMQfXqfqcK321og+uMssc6HYcygUMrGvPnNJyJ1RqZPFDrgtg==}
|
resolution: {integrity: sha512-gM/FdNsK3BlrD6JRrhmiyqBXQsCpzSUdKSoZwJMQfXqfqcK321og+uMssc6HYcygUMrGvPnNJyJ1RqZPFDrgtg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@ljharb/resumer@0.0.1':
|
|
||||||
resolution: {integrity: sha512-skQiAOrCfO7vRTq53cxznMpks7wS1va95UCidALlOVWqvBAzwPVErwizDwoMqNVMEn1mDq0utxZd02eIrvF1lw==}
|
|
||||||
engines: {node: '>= 0.4'}
|
|
||||||
|
|
||||||
'@ljharb/through@2.3.11':
|
'@ljharb/through@2.3.11':
|
||||||
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
|
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -15298,10 +15294,6 @@ snapshots:
|
|||||||
|
|
||||||
'@lemonsqueezy/lemonsqueezy.js@3.3.1': {}
|
'@lemonsqueezy/lemonsqueezy.js@3.3.1': {}
|
||||||
|
|
||||||
'@ljharb/resumer@0.0.1':
|
|
||||||
dependencies:
|
|
||||||
'@ljharb/through': 2.3.11
|
|
||||||
|
|
||||||
'@ljharb/through@2.3.11':
|
'@ljharb/through@2.3.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.7
|
call-bind: 1.0.7
|
||||||
|
@ -9,6 +9,7 @@ import { token } from '../model/notification/token/index.js';
|
|||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import { sendFeedEventsNotify } from '../model/feed/event.js';
|
import { sendFeedEventsNotify } from '../model/feed/event.js';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
|
import { checkWorkspaceUsage } from '../model/billing/cronjob.js';
|
||||||
|
|
||||||
type WebsiteEventCountSqlReturn = {
|
type WebsiteEventCountSqlReturn = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
@ -29,6 +30,10 @@ export function initCronjob() {
|
|||||||
checkFeedEventsNotify(FeedChannelNotifyFrequency.day),
|
checkFeedEventsNotify(FeedChannelNotifyFrequency.day),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (env.billing.enable) {
|
||||||
|
await checkWorkspaceUsage();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Daily cronjob completed');
|
logger.info('Daily cronjob completed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Daily cronjob error:', err);
|
logger.error('Daily cronjob error:', err);
|
||||||
@ -386,6 +391,9 @@ async function dailyHTTPCertCheckNotify() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check feed events notify
|
||||||
|
*/
|
||||||
async function checkFeedEventsNotify(
|
async function checkFeedEventsNotify(
|
||||||
notifyFrequency: FeedChannelNotifyFrequency
|
notifyFrequency: FeedChannelNotifyFrequency
|
||||||
) {
|
) {
|
||||||
|
65
src/server/model/billing/cronjob.ts
Normal file
65
src/server/model/billing/cronjob.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import pMap from 'p-map';
|
||||||
|
import { prisma } from '../_client.js';
|
||||||
|
import { WorkspaceSubscriptionTier } from '@prisma/client';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { getTierLimit } from './limit.js';
|
||||||
|
import { getWorkspaceUsage, pauseWorkspace } from './workspace.js';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { getWorkspaceServiceCount } from '../workspace.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check workspace usage
|
||||||
|
* if over limit, pause workspace
|
||||||
|
*/
|
||||||
|
export async function checkWorkspaceUsage() {
|
||||||
|
logger.info('[checkWorkspaceUsage] Start run checkWorkspaceUsage');
|
||||||
|
|
||||||
|
const workspaces = await prisma.workspace.findMany({
|
||||||
|
where: {
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await pMap(
|
||||||
|
workspaces,
|
||||||
|
async (workspace) => {
|
||||||
|
const tier =
|
||||||
|
workspace.subscription?.tier ?? WorkspaceSubscriptionTier.FREE;
|
||||||
|
|
||||||
|
if (tier === WorkspaceSubscriptionTier.UNLIMITED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [usage, serviceCount] = await Promise.all([
|
||||||
|
getWorkspaceUsage(
|
||||||
|
workspace.id,
|
||||||
|
dayjs().startOf('month').valueOf(),
|
||||||
|
dayjs().valueOf()
|
||||||
|
),
|
||||||
|
getWorkspaceServiceCount(workspace.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const limit = getTierLimit(tier);
|
||||||
|
|
||||||
|
const overUsage =
|
||||||
|
serviceCount.website > limit.maxWebsiteCount ||
|
||||||
|
usage.websiteEventCount > limit.maxWebsiteEventCount ||
|
||||||
|
usage.monitorExecutionCount > limit.maxMonitorExecutionCount ||
|
||||||
|
usage.websiteEventCount > limit.maxWebsiteEventCount ||
|
||||||
|
usage.surveyCount > limit.maxSurveyCount ||
|
||||||
|
serviceCount.feed > limit.maxFeedChannelCount ||
|
||||||
|
usage.feedEventCount > limit.maxFeedEventCount;
|
||||||
|
|
||||||
|
if (overUsage) {
|
||||||
|
// pause workspace
|
||||||
|
await pauseWorkspace(workspace.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
concurrency: 5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { TierType } from './types.js';
|
import { WorkspaceSubscriptionTier } from '@prisma/client';
|
||||||
|
|
||||||
interface TierLimit {
|
interface TierLimit {
|
||||||
maxWebsiteCount: number;
|
maxWebsiteCount: number;
|
||||||
@ -12,8 +12,8 @@ interface TierLimit {
|
|||||||
/**
|
/**
|
||||||
* Limit, Every month
|
* Limit, Every month
|
||||||
*/
|
*/
|
||||||
export function getTierLimit(tier: TierType): TierLimit {
|
export function getTierLimit(tier: WorkspaceSubscriptionTier): TierLimit {
|
||||||
if (tier === 'free') {
|
if (tier === WorkspaceSubscriptionTier.FREE) {
|
||||||
return {
|
return {
|
||||||
maxWebsiteCount: 3,
|
maxWebsiteCount: 3,
|
||||||
maxWebsiteEventCount: 100_000,
|
maxWebsiteEventCount: 100_000,
|
||||||
@ -24,7 +24,7 @@ export function getTierLimit(tier: TierType): TierLimit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tier === 'pro') {
|
if (tier === WorkspaceSubscriptionTier.PRO) {
|
||||||
return {
|
return {
|
||||||
maxWebsiteCount: 10,
|
maxWebsiteCount: 10,
|
||||||
maxWebsiteEventCount: 1_000_000,
|
maxWebsiteEventCount: 1_000_000,
|
||||||
@ -35,7 +35,7 @@ export function getTierLimit(tier: TierType): TierLimit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tier === 'team') {
|
if (tier === WorkspaceSubscriptionTier.TEAM) {
|
||||||
return {
|
return {
|
||||||
maxWebsiteCount: -1,
|
maxWebsiteCount: -1,
|
||||||
maxWebsiteEventCount: 20_000_000,
|
maxWebsiteEventCount: 20_000_000,
|
||||||
@ -46,6 +46,7 @@ export function getTierLimit(tier: TierType): TierLimit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unlimited
|
||||||
return {
|
return {
|
||||||
maxWebsiteCount: -1,
|
maxWebsiteCount: -1,
|
||||||
maxWebsiteEventCount: -1,
|
maxWebsiteEventCount: -1,
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export type TierType = 'free' | 'pro' | 'team' | 'unlimited';
|
|
56
src/server/model/billing/workspace.ts
Normal file
56
src/server/model/billing/workspace.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { WorkspaceSubscriptionTier } from '@prisma/client';
|
||||||
|
import { prisma } from '../_client.js';
|
||||||
|
|
||||||
|
export async function getWorkspaceUsage(
|
||||||
|
workspaceId: string,
|
||||||
|
startAt: number,
|
||||||
|
endAt: number
|
||||||
|
) {
|
||||||
|
const res = await prisma.workspaceDailyUsage.aggregate({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
date: {
|
||||||
|
gte: new Date(startAt),
|
||||||
|
lte: new Date(endAt),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
websiteAcceptedCount: true,
|
||||||
|
websiteEventCount: true,
|
||||||
|
monitorExecutionCount: true,
|
||||||
|
surveyCount: true,
|
||||||
|
feedEventCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
websiteAcceptedCount: res._sum.websiteAcceptedCount ?? 0,
|
||||||
|
websiteEventCount: res._sum.websiteEventCount ?? 0,
|
||||||
|
monitorExecutionCount: res._sum.monitorExecutionCount ?? 0,
|
||||||
|
surveyCount: res._sum.surveyCount ?? 0,
|
||||||
|
feedEventCount: res._sum.feedEventCount ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspaceSubscription(
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<WorkspaceSubscriptionTier> {
|
||||||
|
const subscription = await prisma.workspaceSubscription.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription?.tier ?? WorkspaceSubscriptionTier.FREE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pauseWorkspace(workspaceId: string) {
|
||||||
|
await prisma.workspace.update({
|
||||||
|
where: {
|
||||||
|
id: workspaceId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
paused: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -72,3 +72,47 @@ export async function getWorkspaceWebsiteDateRange(websiteId: string) {
|
|||||||
min: res._min.createdAt,
|
min: res._min.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorkspaceServiceCount(workspaceId: string) {
|
||||||
|
const [website, monitor, telemetry, page, survey, feed] = await Promise.all([
|
||||||
|
prisma.website.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.monitor.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.telemetry.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.monitorStatusPage.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.survey.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.feedChannel.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
website,
|
||||||
|
monitor,
|
||||||
|
telemetry,
|
||||||
|
page,
|
||||||
|
survey,
|
||||||
|
feed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -60,6 +60,7 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
|
"p-map": "4.0.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"ping": "^0.4.4",
|
"ping": "^0.4.4",
|
||||||
@ -101,7 +102,6 @@
|
|||||||
"@types/tcp-ping": "^0.1.5",
|
"@types/tcp-ping": "^0.1.5",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"execa": "^5.1.1",
|
"execa": "^5.1.1",
|
||||||
"p-map": "4.0.0",
|
|
||||||
"prisma": "5.14.0",
|
"prisma": "5.14.0",
|
||||||
"prisma-json-types-generator": "3.0.3",
|
"prisma-json-types-generator": "3.0.3",
|
||||||
"prisma-zod-generator": "0.8.13",
|
"prisma-zod-generator": "0.8.13",
|
||||||
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Workspace" ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false;
|
@ -96,6 +96,7 @@ model Workspace {
|
|||||||
/// [CommonPayload]
|
/// [CommonPayload]
|
||||||
/// @zod.custom(imports.CommonPayloadSchema)
|
/// @zod.custom(imports.CommonPayloadSchema)
|
||||||
settings Json @default("{}")
|
settings Json @default("{}")
|
||||||
|
paused Boolean @default(false) // if workspace over billing, its will marked as pause and not receive and input.
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ export const WorkspaceModelSchema = z.object({
|
|||||||
* [CommonPayload]
|
* [CommonPayload]
|
||||||
*/
|
*/
|
||||||
settings: imports.CommonPayloadSchema,
|
settings: imports.CommonPayloadSchema,
|
||||||
|
paused: z.boolean(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
})
|
})
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
SubscriptionTierType,
|
SubscriptionTierType,
|
||||||
} from '../../model/billing/index.js';
|
} from '../../model/billing/index.js';
|
||||||
import { LemonSqueezySubscriptionModelSchema } from '../../prisma/zod/lemonsqueezysubscription.js';
|
import { LemonSqueezySubscriptionModelSchema } from '../../prisma/zod/lemonsqueezysubscription.js';
|
||||||
|
import { getWorkspaceUsage } from '../../model/billing/workspace.js';
|
||||||
|
|
||||||
export const billingRouter = router({
|
export const billingRouter = router({
|
||||||
usage: workspaceProcedure
|
usage: workspaceProcedure
|
||||||
@ -44,30 +45,7 @@ export const billingRouter = router({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { workspaceId, startAt, endAt } = input;
|
const { workspaceId, startAt, endAt } = input;
|
||||||
|
|
||||||
const res = await prisma.workspaceDailyUsage.aggregate({
|
return getWorkspaceUsage(workspaceId, startAt, endAt);
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
date: {
|
|
||||||
gte: new Date(startAt),
|
|
||||||
lte: new Date(endAt),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_sum: {
|
|
||||||
websiteAcceptedCount: true,
|
|
||||||
websiteEventCount: true,
|
|
||||||
monitorExecutionCount: true,
|
|
||||||
surveyCount: true,
|
|
||||||
feedEventCount: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
websiteAcceptedCount: res._sum.websiteAcceptedCount ?? 0,
|
|
||||||
websiteEventCount: res._sum.websiteEventCount ?? 0,
|
|
||||||
monitorExecutionCount: res._sum.monitorExecutionCount ?? 0,
|
|
||||||
surveyCount: res._sum.surveyCount ?? 0,
|
|
||||||
feedEventCount: res._sum.feedEventCount ?? 0,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
currentSubscription: workspaceProcedure
|
currentSubscription: workspaceProcedure
|
||||||
.meta(
|
.meta(
|
||||||
|
@ -28,6 +28,7 @@ import { WorkspacesOnUsersModelSchema } from '../../prisma/zod/workspacesonusers
|
|||||||
import { monitorManager } from '../../model/monitor/index.js';
|
import { monitorManager } from '../../model/monitor/index.js';
|
||||||
import { get, merge } from 'lodash-es';
|
import { get, merge } from 'lodash-es';
|
||||||
import { promWorkspaceCounter } from '../../utils/prometheus/client.js';
|
import { promWorkspaceCounter } from '../../utils/prometheus/client.js';
|
||||||
|
import { getWorkspaceServiceCount } from '../../model/workspace.js';
|
||||||
|
|
||||||
export const workspaceRouter = router({
|
export const workspaceRouter = router({
|
||||||
create: protectProedure
|
create: protectProedure
|
||||||
@ -382,39 +383,8 @@ export const workspaceRouter = router({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { workspaceId } = input;
|
const { workspaceId } = input;
|
||||||
|
|
||||||
const [website, monitor, telemetry, page, survey, feed] =
|
const { website, monitor, telemetry, page, survey, feed } =
|
||||||
await Promise.all([
|
await getWorkspaceServiceCount(workspaceId);
|
||||||
prisma.website.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.monitor.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.telemetry.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.monitorStatusPage.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.survey.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.feedChannel.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const server = getServerCount(workspaceId);
|
const server = getServerCount(workspaceId);
|
||||||
|
|
||||||
|
@ -47,6 +47,7 @@ export const env = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
|
enable: process.env.ENABLE_BILLING,
|
||||||
lemonSqueezy: {
|
lemonSqueezy: {
|
||||||
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
|
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
|
||||||
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',
|
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',
|
||||||
|
Loading…
Reference in New Issue
Block a user