feat(subscriptions): added polar as payment provider for subscriptions
* feature(dashboard): add polar / subscription * wip(payments): manage subscription * wip(payments): add free product, faq and some other improvements * fix(root): change node to bundler in tsconfig * wip(payments): display current subscription * feat(dashboard): schedule project for deletion * wip(payments): support custom products/subscriptions * wip(payments): fix polar scripts * wip(payments): add json package to dockerfiles
This commit is contained in:
committed by
GitHub
parent
86bf9dd064
commit
168ebc3430
@@ -30,11 +30,13 @@ COPY apps/api/package.json ./apps/api/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/trpc/package.json packages/trpc/
|
||||
COPY packages/auth/package.json packages/auth/
|
||||
COPY packages/json/package.json packages/json/
|
||||
COPY packages/email/package.json packages/email/
|
||||
COPY packages/queue/package.json packages/queue/
|
||||
COPY packages/redis/package.json packages/redis/
|
||||
COPY packages/logger/package.json packages/logger/
|
||||
COPY packages/common/package.json packages/common/
|
||||
COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
@@ -91,12 +93,13 @@ COPY --from=build /app/apps/api ./apps/api
|
||||
COPY --from=build /app/packages/db ./packages/db
|
||||
COPY --from=build /app/packages/auth ./packages/auth
|
||||
COPY --from=build /app/packages/trpc ./packages/trpc
|
||||
COPY --from=build /app/packages/auth ./packages/auth
|
||||
COPY --from=build /app/packages/json ./packages/json
|
||||
COPY --from=build /app/packages/email ./packages/email
|
||||
COPY --from=build /app/packages/queue ./packages/queue
|
||||
COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/common ./packages/common
|
||||
COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
@@ -30,6 +32,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"fastify": "^4.25.2",
|
||||
"fastify-metrics": "^11.0.0",
|
||||
"fastify-raw-body": "^4.2.1",
|
||||
"ico-to-png": "^0.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
@@ -54,7 +57,7 @@
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/ws": "^8.5.14",
|
||||
"js-yaml": "^4.1.0",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
|
||||
84
apps/api/scripts/test.ts
Normal file
84
apps/api/scripts/test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { type IClickhouseEvent, ch, createEvent } from '@openpanel/db';
|
||||
import { formatClickhouseDate } from '@openpanel/db';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
async function main() {
|
||||
const startDate = new Date('2025-01-01T00:00:00Z');
|
||||
const endDate = new Date();
|
||||
const eventsPerDay = 25000;
|
||||
const variance = 3000;
|
||||
|
||||
// Event names to randomly choose from
|
||||
const eventNames = ['click', 'purchase', 'signup', 'login', 'screen_view'];
|
||||
|
||||
// Loop through each day
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
currentDate <= endDate;
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
) {
|
||||
const events: IClickhouseEvent[] = [];
|
||||
// Calculate random number of events for this day
|
||||
const dailyEvents =
|
||||
eventsPerDay + Math.floor(Math.random() * variance * 2) - variance;
|
||||
|
||||
// Create events for the day
|
||||
for (let i = 0; i < dailyEvents; i++) {
|
||||
const eventTime = new Date(currentDate);
|
||||
// Distribute events throughout the day
|
||||
eventTime.setHours(Math.floor(Math.random() * 24));
|
||||
eventTime.setMinutes(Math.floor(Math.random() * 60));
|
||||
eventTime.setSeconds(Math.floor(Math.random() * 60));
|
||||
|
||||
events.push({
|
||||
id: uuid(),
|
||||
name: eventNames[Math.floor(Math.random() * eventNames.length)]!,
|
||||
device_id: `device_${Math.floor(Math.random() * 1000)}`,
|
||||
profile_id: `profile_${Math.floor(Math.random() * 1000)}`,
|
||||
project_id: 'testing',
|
||||
session_id: `session_${Math.floor(Math.random() * 10000)}`,
|
||||
properties: {
|
||||
hash: 'test-hash',
|
||||
'query.utm_source': 'test',
|
||||
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
|
||||
__user_agent: 'Mozilla/5.0 (Test)',
|
||||
},
|
||||
created_at: formatClickhouseDate(eventTime),
|
||||
country: 'US',
|
||||
city: 'New York',
|
||||
region: 'NY',
|
||||
longitude: -74.006,
|
||||
latitude: 40.7128,
|
||||
os: 'macOS',
|
||||
os_version: '13.0',
|
||||
browser: 'Chrome',
|
||||
browser_version: '120.0',
|
||||
device: 'desktop',
|
||||
brand: 'Apple',
|
||||
model: 'MacBook Pro',
|
||||
duration: Math.floor(Math.random() * 300),
|
||||
path: `/page-${Math.floor(Math.random() * 20)}`,
|
||||
origin: 'https://example.com',
|
||||
referrer: 'https://google.com',
|
||||
referrer_name: 'Google',
|
||||
referrer_type: 'search',
|
||||
imported_at: null,
|
||||
sdk_name: 'test-script',
|
||||
sdk_version: '1.0.0',
|
||||
});
|
||||
}
|
||||
|
||||
await ch.insert({
|
||||
table: 'events',
|
||||
values: events,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
// Log progress
|
||||
console.log(
|
||||
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,72 +1,35 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { SocketStream } from '@fastify/websocket';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import superjson from 'superjson';
|
||||
import type * as WebSocket from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { getSuperJson } from '@openpanel/common';
|
||||
import type { IServiceEvent, Notification } from '@openpanel/db';
|
||||
import {
|
||||
TABLE_NAMES,
|
||||
eventBuffer,
|
||||
getEvents,
|
||||
getProfileByIdCached,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { getRedisCache, getRedisPub, getRedisSub } from '@openpanel/redis';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import {
|
||||
psubscribeToPublishedEvent,
|
||||
subscribeToPublishedEvent,
|
||||
} from '@openpanel/redis';
|
||||
import { getProjectAccess } from '@openpanel/trpc';
|
||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||
|
||||
type WebSocketConnection = SocketStream & {
|
||||
socket: WebSocket & {
|
||||
on(event: 'close', listener: () => void): void;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
};
|
||||
};
|
||||
|
||||
export function getLiveEventInfo(key: string) {
|
||||
return key.split(':').slice(2) as [string, string];
|
||||
}
|
||||
|
||||
export async function testVisitors(
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const events = await getEvents(
|
||||
`SELECT * FROM ${TABLE_NAMES.events} LIMIT 500`,
|
||||
);
|
||||
const event = events[Math.floor(Math.random() * events.length)];
|
||||
if (!event) {
|
||||
return reply.status(404).send('No event found');
|
||||
}
|
||||
event.projectId = req.params.projectId;
|
||||
getRedisPub().publish('event:received', superjson.stringify(event));
|
||||
getRedisCache().set(
|
||||
`live:event:${event.projectId}:${Math.random() * 1000}`,
|
||||
'',
|
||||
'EX',
|
||||
10,
|
||||
);
|
||||
reply.status(202).send(event);
|
||||
}
|
||||
|
||||
export async function testEvents(
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const events = await getEvents(
|
||||
`SELECT * FROM ${TABLE_NAMES.events} LIMIT 500`,
|
||||
);
|
||||
const event = events[Math.floor(Math.random() * events.length)];
|
||||
if (!event) {
|
||||
return reply.status(404).send('No event found');
|
||||
}
|
||||
getRedisPub().publish('event:saved', superjson.stringify(event));
|
||||
reply.status(202).send(event);
|
||||
}
|
||||
|
||||
export function wsVisitors(
|
||||
connection: {
|
||||
socket: WebSocket;
|
||||
},
|
||||
connection: WebSocketConnection,
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
@@ -75,60 +38,46 @@ export function wsVisitors(
|
||||
) {
|
||||
const { params } = req;
|
||||
|
||||
getRedisSub().subscribe('event:received');
|
||||
getRedisSub().psubscribe('__keyevent@0__:expired');
|
||||
|
||||
const message = (channel: string, message: string) => {
|
||||
if (channel === 'event:received') {
|
||||
const event = getSuperJson<IServiceEvent>(message);
|
||||
if (event?.projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
connection.socket.send(String(count));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const pmessage = (pattern: string, channel: string, message: string) => {
|
||||
if (!message.startsWith('live:visitor:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [projectId] = getLiveEventInfo(message);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
|
||||
if (event?.projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
connection.socket.send(String(count));
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
getRedisSub().on('message', message);
|
||||
getRedisSub().on('pmessage', pmessage);
|
||||
const punsubscribe = psubscribeToPublishedEvent(
|
||||
'__keyevent@0__:expired',
|
||||
(key) => {
|
||||
const [projectId] = getLiveEventInfo(key);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
connection.socket.send(String(count));
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
connection.socket.on('close', () => {
|
||||
getRedisSub().unsubscribe('event:saved');
|
||||
getRedisSub().punsubscribe('__keyevent@0__:expired');
|
||||
getRedisSub().off('message', message);
|
||||
getRedisSub().off('pmessage', pmessage);
|
||||
unsubscribe();
|
||||
punsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
export async function wsProjectEvents(
|
||||
connection: {
|
||||
socket: WebSocket;
|
||||
},
|
||||
connection: WebSocketConnection,
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
Querystring: {
|
||||
token?: string;
|
||||
type?: string;
|
||||
type?: 'saved' | 'received';
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const { params, query } = req;
|
||||
const type = query.type || 'saved';
|
||||
const subscribeToEvent = `event:${type}`;
|
||||
|
||||
if (!['saved', 'received'].includes(type)) {
|
||||
connection.socket.send('Invalid type');
|
||||
@@ -148,12 +97,11 @@ export async function wsProjectEvents(
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
getRedisSub().subscribe(subscribeToEvent);
|
||||
|
||||
const message = async (channel: string, message: string) => {
|
||||
if (channel === subscribeToEvent) {
|
||||
const event = getSuperJson<IServiceEvent>(message);
|
||||
if (event?.projectId === params.projectId) {
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileByIdCached(
|
||||
event.profileId,
|
||||
event.projectId,
|
||||
@@ -169,31 +117,21 @@ export async function wsProjectEvents(
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
getRedisSub().on('message', message as any);
|
||||
|
||||
connection.socket.on('close', () => {
|
||||
getRedisSub().unsubscribe(subscribeToEvent);
|
||||
getRedisSub().off('message', message as any);
|
||||
});
|
||||
connection.socket.on('close', () => unsubscribe());
|
||||
}
|
||||
|
||||
export async function wsProjectNotifications(
|
||||
connection: {
|
||||
socket: WebSocket;
|
||||
},
|
||||
connection: WebSocketConnection,
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
Querystring: {
|
||||
token?: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const { params, query } = req;
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
|
||||
if (!userId) {
|
||||
@@ -202,8 +140,6 @@ export async function wsProjectNotifications(
|
||||
return;
|
||||
}
|
||||
|
||||
const subscribeToEvent = 'notification';
|
||||
|
||||
const access = await getProjectAccess({
|
||||
userId,
|
||||
projectId: params.projectId,
|
||||
@@ -215,21 +151,54 @@ export async function wsProjectNotifications(
|
||||
return;
|
||||
}
|
||||
|
||||
getRedisSub().subscribe(subscribeToEvent);
|
||||
|
||||
const message = async (channel: string, message: string) => {
|
||||
if (channel === subscribeToEvent) {
|
||||
const notification = getSuperJson<Notification>(message);
|
||||
if (notification?.projectId === params.projectId) {
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'notification',
|
||||
'created',
|
||||
(notification) => {
|
||||
if (notification.projectId === params.projectId) {
|
||||
connection.socket.send(superjson.stringify(notification));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
getRedisSub().on('message', message as any);
|
||||
|
||||
connection.socket.on('close', () => {
|
||||
getRedisSub().unsubscribe(subscribeToEvent);
|
||||
getRedisSub().off('message', message as any);
|
||||
});
|
||||
connection.socket.on('close', () => unsubscribe());
|
||||
}
|
||||
|
||||
export async function wsOrganizationEvents(
|
||||
connection: WebSocketConnection,
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
|
||||
if (!userId) {
|
||||
connection.socket.send('No active session');
|
||||
connection.socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await getOrganizationAccess({
|
||||
userId,
|
||||
organizationId: params.organizationId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
connection.socket.send('No access');
|
||||
connection.socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'organization',
|
||||
'subscription_updated',
|
||||
(message) => {
|
||||
connection.socket.send(setSuperJson(message));
|
||||
},
|
||||
);
|
||||
|
||||
connection.socket.on('close', () => unsubscribe());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
sendSlackNotification,
|
||||
slackInstaller,
|
||||
} from '@openpanel/integrations/src/slack';
|
||||
import {
|
||||
PolarWebhookVerificationError,
|
||||
getProduct,
|
||||
validatePolarEvent,
|
||||
} from '@openpanel/payments';
|
||||
import { publishEvent } from '@openpanel/redis';
|
||||
import { zSlackAuthResponse } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
@@ -102,3 +108,123 @@ export async function slackWebhook(
|
||||
return reply.status(500).header('Content-Type', 'text/html').send(html);
|
||||
}
|
||||
}
|
||||
|
||||
export async function polarWebhook(
|
||||
request: FastifyRequest<{
|
||||
Querystring: unknown;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
try {
|
||||
const event = validatePolarEvent(
|
||||
request.rawBody!,
|
||||
request.headers as Record<string, string>,
|
||||
process.env.POLAR_WEBHOOK_SECRET ?? '',
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
case 'order.created': {
|
||||
const metadata = z
|
||||
.object({
|
||||
organizationId: z.string(),
|
||||
})
|
||||
.parse(event.data.metadata);
|
||||
|
||||
if (event.data.billingReason === 'subscription_cycle') {
|
||||
await db.organization.update({
|
||||
where: {
|
||||
id: metadata.organizationId,
|
||||
},
|
||||
data: {
|
||||
subscriptionPeriodEventsCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'subscription.updated': {
|
||||
const metadata = z
|
||||
.object({
|
||||
organizationId: z.string(),
|
||||
userId: z.string(),
|
||||
})
|
||||
.parse(event.data.metadata);
|
||||
|
||||
const product = await getProduct(event.data.productId);
|
||||
const eventsLimit = product.metadata?.eventsLimit;
|
||||
const subscriptionPeriodEventsLimit =
|
||||
typeof eventsLimit === 'number' ? eventsLimit : undefined;
|
||||
|
||||
if (!subscriptionPeriodEventsLimit) {
|
||||
request.log.warn('No events limit found for product', { product });
|
||||
}
|
||||
|
||||
// If we get a cancel event and we cant find it we should ignore it
|
||||
// Since we only have one subscription per organization but you can have several in polar
|
||||
// we dont want to override the existing subscription with a canceled one
|
||||
// TODO: might be other events that we should handle like this?!
|
||||
if (event.data.status === 'canceled') {
|
||||
const orgSubscription = await db.organization.findFirst({
|
||||
where: {
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionStatus: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgSubscription) {
|
||||
return reply.status(202).send('OK');
|
||||
}
|
||||
}
|
||||
|
||||
await db.organization.update({
|
||||
where: {
|
||||
id: metadata.organizationId,
|
||||
},
|
||||
data: {
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionPriceId: event.data.priceId,
|
||||
subscriptionProductId: event.data.productId,
|
||||
subscriptionStatus: event.data.status,
|
||||
subscriptionStartsAt: event.data.currentPeriodStart,
|
||||
subscriptionCanceledAt: event.data.canceledAt,
|
||||
subscriptionEndsAt:
|
||||
event.data.status === 'canceled'
|
||||
? event.data.cancelAtPeriodEnd
|
||||
? event.data.currentPeriodEnd
|
||||
: event.data.canceledAt
|
||||
: event.data.currentPeriodEnd,
|
||||
subscriptionCreatedByUserId: metadata.userId,
|
||||
subscriptionInterval: event.data.recurringInterval,
|
||||
subscriptionPeriodEventsLimit,
|
||||
},
|
||||
});
|
||||
|
||||
await publishEvent('organization', 'subscription_updated', {
|
||||
organizationId: metadata.organizationId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(202).send('OK');
|
||||
} catch (error) {
|
||||
if (error instanceof PolarWebhookVerificationError) {
|
||||
request.log.error('Polar webhook error', { error });
|
||||
reply.status(403).send('');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isToday(date: Date) {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ const startServer = async () => {
|
||||
};
|
||||
});
|
||||
|
||||
await fastify.register(import('fastify-raw-body'), {
|
||||
global: false,
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', ipHook);
|
||||
fastify.addHook('preHandler', timestampHook);
|
||||
fastify.addHook('preHandler', fixHook);
|
||||
|
||||
@@ -2,35 +2,31 @@ import * as controller from '@/controllers/live.controller';
|
||||
import fastifyWS from '@fastify/websocket';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
// TODO: `as any` is a workaround since it starts to break after changed module resolution to bundler
|
||||
// which is needed for @polar/sdk (dont have time to resolve this now)
|
||||
const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/visitors/test/:projectId',
|
||||
handler: controller.testVisitors,
|
||||
});
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/events/test/:projectId',
|
||||
handler: controller.testEvents,
|
||||
});
|
||||
|
||||
fastify.register(fastifyWS);
|
||||
|
||||
fastify.register((fastify, _, done) => {
|
||||
fastify.get(
|
||||
'/organization/:organizationId',
|
||||
{ websocket: true },
|
||||
controller.wsOrganizationEvents as any,
|
||||
);
|
||||
fastify.get(
|
||||
'/visitors/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsVisitors,
|
||||
controller.wsVisitors as any,
|
||||
);
|
||||
fastify.get(
|
||||
'/events/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsProjectEvents,
|
||||
controller.wsProjectEvents as any,
|
||||
);
|
||||
fastify.get(
|
||||
'/notifications/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsProjectNotifications,
|
||||
controller.wsProjectNotifications as any,
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,14 @@ const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||
url: '/slack',
|
||||
handler: controller.slackWebhook,
|
||||
});
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/polar',
|
||||
handler: controller.polarWebhook,
|
||||
config: {
|
||||
rawBody: true,
|
||||
},
|
||||
});
|
||||
done();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSafeJson } from '@openpanel/common';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
|
||||
export const parseQueryString = (obj: Record<string, any>): any => {
|
||||
return Object.fromEntries(
|
||||
|
||||
@@ -34,6 +34,7 @@ COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/dashboard/package.json apps/dashboard/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/json/package.json packages/json/package.json
|
||||
COPY packages/redis/package.json packages/redis/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/common/package.json packages/common/package.json
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// @ts-expect-error
|
||||
import { PrismaPlugin } from '@prisma/nextjs-monorepo-workaround-plugin';
|
||||
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
await import('./src/env.mjs');
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
output: 'standalone',
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@openpanel/constants": "workspace:^",
|
||||
"@openpanel/db": "workspace:^",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/nextjs": "1.0.3",
|
||||
"@openpanel/queue": "workspace:^",
|
||||
"@openpanel/sdk-info": "workspace:^",
|
||||
@@ -114,6 +115,7 @@
|
||||
"devDependencies": {
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
|
||||
@@ -4,13 +4,16 @@ import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
BanknoteIcon,
|
||||
ChartLineIcon,
|
||||
DollarSignIcon,
|
||||
GanttChartIcon,
|
||||
Globe2Icon,
|
||||
LayersIcon,
|
||||
LayoutPanelTopIcon,
|
||||
PlusIcon,
|
||||
ScanEyeIcon,
|
||||
ServerIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -18,7 +21,9 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import type { IServiceDashboards } from '@openpanel/db';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
||||
import { differenceInDays, format } from 'date-fns';
|
||||
|
||||
function LinkWithIcon({
|
||||
href,
|
||||
@@ -52,25 +57,114 @@ function LinkWithIcon({
|
||||
|
||||
interface LayoutMenuProps {
|
||||
dashboards: IServiceDashboards;
|
||||
organization: IServiceOrganization;
|
||||
}
|
||||
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||
export default function LayoutMenu({
|
||||
dashboards,
|
||||
organization,
|
||||
}: LayoutMenuProps) {
|
||||
const number = useNumber();
|
||||
const {
|
||||
isTrial,
|
||||
isExpired,
|
||||
isExceeded,
|
||||
isCanceled,
|
||||
subscriptionEndsAt,
|
||||
subscriptionPeriodEventsCount,
|
||||
subscriptionPeriodEventsLimit,
|
||||
} = organization;
|
||||
return (
|
||||
<>
|
||||
<ProjectLink
|
||||
href={'/reports'}
|
||||
className={cn(
|
||||
'border rounded p-2 row items-center gap-2 hover:bg-def-200 mb-4',
|
||||
<div className="col border rounded mb-2 divide-y">
|
||||
{process.env.SELF_HOSTED && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
className={cn(
|
||||
'rounded p-2 row items-center gap-2 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<ServerIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">Self-hosted</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
>
|
||||
<ChartLineIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">Create report</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Visualize your events
|
||||
{isTrial && subscriptionEndsAt && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
className={cn(
|
||||
'rounded p-2 row items-center gap-2 hover:bg-def-200 text-destructive',
|
||||
)}
|
||||
>
|
||||
<BanknoteIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">
|
||||
Free trial ends in{' '}
|
||||
{differenceInDays(subscriptionEndsAt, new Date())} days
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
{isExpired && subscriptionEndsAt && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
className={cn(
|
||||
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
|
||||
)}
|
||||
>
|
||||
<BanknoteIcon size={20} />
|
||||
<div className="flex-1 col gap-0.5">
|
||||
<div className="font-medium">Subscription expired</div>
|
||||
<div className="text-sm opacity-80">
|
||||
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
{isCanceled && subscriptionEndsAt && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
className={cn(
|
||||
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
|
||||
)}
|
||||
>
|
||||
<BanknoteIcon size={20} />
|
||||
<div className="flex-1 col gap-0.5">
|
||||
<div className="font-medium">Subscription canceled</div>
|
||||
<div className="text-sm opacity-80">
|
||||
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
{isExceeded && subscriptionEndsAt && (
|
||||
<ProjectLink
|
||||
href={'/settings/organization?tab=billing'}
|
||||
className={cn(
|
||||
'rounded p-2 row gap-2 hover:bg-def-200 text-destructive',
|
||||
)}
|
||||
>
|
||||
<BanknoteIcon size={20} />
|
||||
<div className="flex-1 col gap-0.5">
|
||||
<div className="font-medium">Events limit exceeded</div>
|
||||
<div className="text-sm opacity-80">
|
||||
{number.format(subscriptionPeriodEventsCount)} /{' '}
|
||||
{number.format(subscriptionPeriodEventsLimit)}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
<ProjectLink
|
||||
href={'/reports'}
|
||||
className={cn('rounded p-2 row gap-2 hover:bg-def-200')}
|
||||
>
|
||||
<ChartLineIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">Create report</div>
|
||||
</div>
|
||||
</div>
|
||||
<PlusIcon size={16} className="text-muted-foreground" />
|
||||
</ProjectLink>
|
||||
<PlusIcon size={16} className="text-muted-foreground" />
|
||||
</ProjectLink>
|
||||
</div>
|
||||
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
|
||||
<LinkWithIcon
|
||||
icon={LayoutPanelTopIcon}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
getProjectsByOrganizationId,
|
||||
} from '@openpanel/db';
|
||||
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import Link from 'next/link';
|
||||
import LayoutMenu from './layout-menu';
|
||||
import LayoutProjectSelector from './layout-project-selector';
|
||||
@@ -31,6 +32,8 @@ export function LayoutSidebar({
|
||||
}: LayoutSidebarProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { organizationId } = useAppParams();
|
||||
const organization = organizations.find((o) => o.id === organizationId)!;
|
||||
|
||||
useEffect(() => {
|
||||
setActive(false);
|
||||
@@ -76,7 +79,7 @@ export function LayoutSidebar({
|
||||
<SettingsToggle />
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
|
||||
<LayoutMenu dashboards={dashboards} />
|
||||
<LayoutMenu dashboards={dashboards} organization={organization} />
|
||||
</div>
|
||||
<div className="fixed bottom-0 left-0 right-0">
|
||||
<div className="h-8 w-full bg-gradient-to-t from-card to-card/0" />
|
||||
|
||||
@@ -42,9 +42,7 @@ function Tooltip(props: any) {
|
||||
|
||||
const Chart = ({ data }: Props) => {
|
||||
const xAxisProps = useXAxisProps();
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: data.map((d) => d.users),
|
||||
});
|
||||
const yAxisProps = useYAxisProps();
|
||||
return (
|
||||
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||
<ResponsiveContainer>
|
||||
|
||||
@@ -59,9 +59,7 @@ const Chart = ({ data }: Props) => {
|
||||
mau: data.monthly.find((m) => m.date === d.date)?.users,
|
||||
}));
|
||||
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: data.monthly.map((d) => d.users),
|
||||
});
|
||||
const yAxisProps = useYAxisProps();
|
||||
return (
|
||||
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||
<ResponsiveContainer>
|
||||
|
||||
@@ -57,9 +57,7 @@ function Tooltip({ payload }: any) {
|
||||
|
||||
const Chart = ({ data }: Props) => {
|
||||
const xAxisProps = useXAxisProps();
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: data.map((d) => d.retention),
|
||||
});
|
||||
const yAxisProps = useYAxisProps();
|
||||
return (
|
||||
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||
<ResponsiveContainer>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: "What's the free tier?",
|
||||
answer: [
|
||||
'You get 5000 events per month for free. This is mostly for you to try out OpenPanel but also for solo developers or people who want to try out OpenPanel without committing to a paid plan.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What happens if my site exceeds the limit?',
|
||||
answer: [
|
||||
"You will not see any new events in OpenPanel until your next billing period. If this happens 2 months in a row, we'll advice you to upgrade your plan.",
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What happens if I cancel my subscription?',
|
||||
answer: [
|
||||
'If you cancel your subscription, you will still have access to OpenPanel until the end of your current billing period. You can reactivate your subscription at any time.',
|
||||
'After your current billing period ends, you will not get access to new data.',
|
||||
"NOTE: If your account has been inactive for 3 months, we'll delete your events.",
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How do I change my billing information?',
|
||||
answer: [
|
||||
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function BillingFaq() {
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Usage</span>
|
||||
</WidgetHead>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center"
|
||||
>
|
||||
{questions.map((q) => (
|
||||
<AccordionItem value={q.question} key={q.question}>
|
||||
<AccordionTrigger className="text-left px-4">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="col gap-2 p-4 pt-2">
|
||||
{q.answer.map((a) => (
|
||||
<p key={a}>{a}</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { api } from '@/trpc/client';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import type { IPolarPrice } from '@openpanel/payments';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Props = {
|
||||
organization: IServiceOrganization;
|
||||
};
|
||||
|
||||
export default function Billing({ organization }: Props) {
|
||||
const router = useRouter();
|
||||
const { projectId } = useAppParams();
|
||||
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
|
||||
'customer_session_token',
|
||||
);
|
||||
const productsQuery = api.subscription.products.useQuery({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
useWS(`/live/organization/${organization.id}`, (event) => {
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
|
||||
(organization.subscriptionInterval as 'year' | 'month') || 'month',
|
||||
);
|
||||
|
||||
const products = useMemo(() => {
|
||||
return (productsQuery.data || []).filter(
|
||||
(product) => product.recurringInterval === recurringInterval,
|
||||
);
|
||||
}, [productsQuery.data, recurringInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organization.subscriptionInterval) {
|
||||
setRecurringInterval(
|
||||
organization.subscriptionInterval as 'year' | 'month',
|
||||
);
|
||||
}
|
||||
}, [organization.subscriptionInterval]);
|
||||
|
||||
function renderBillingTable() {
|
||||
if (productsQuery.isLoading) {
|
||||
return (
|
||||
<div className="center-center p-8">
|
||||
<Loader2Icon className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (productsQuery.isError) {
|
||||
return (
|
||||
<div className="center-center p-8 font-medium">
|
||||
Issues loading all tiers
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WidgetTable
|
||||
className="w-full max-w-full [&_td]:text-left"
|
||||
data={products}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
name: 'Tier',
|
||||
render(item) {
|
||||
return <div className="font-medium">{item.name}</div>;
|
||||
},
|
||||
className: 'w-full',
|
||||
},
|
||||
{
|
||||
name: 'Price',
|
||||
render(item) {
|
||||
const price = item.prices[0];
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (price.amountType === 'free') {
|
||||
return (
|
||||
<div className="row gap-2 whitespace-nowrap">
|
||||
<div className="items-center text-right justify-end gap-4 flex-1 row">
|
||||
<span>Free</span>
|
||||
<CheckoutButton
|
||||
disabled={item.disabled}
|
||||
key={price.id}
|
||||
price={price}
|
||||
organization={organization}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (price.amountType !== 'fixed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row gap-2 whitespace-nowrap">
|
||||
<div className="items-center text-right justify-end gap-4 flex-1 row">
|
||||
<span>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: price.priceCurrency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(price.priceAmount / 100)}
|
||||
{' / '}
|
||||
{recurringInterval === 'year' ? 'year' : 'month'}
|
||||
</span>
|
||||
<CheckoutButton
|
||||
disabled={item.disabled}
|
||||
key={price.id}
|
||||
price={price}
|
||||
organization={organization}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Billing</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{recurringInterval === 'year'
|
||||
? 'Yearly (2 months free)'
|
||||
: 'Monthly'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={recurringInterval === 'year'}
|
||||
onCheckedChange={(checked) =>
|
||||
setRecurringInterval(checked ? 'year' : 'month')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<div className="-m-4">
|
||||
{renderBillingTable()}
|
||||
<div className="text-center p-4 border-t">
|
||||
<p>Do you need higher limits? </p>
|
||||
<p>
|
||||
Reach out to{' '}
|
||||
<a
|
||||
className="underline font-medium"
|
||||
href="mailto:hello@openpanel.dev"
|
||||
>
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
and we'll help you out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Dialog
|
||||
open={!!customerSessionToken}
|
||||
onOpenChange={(open) => {
|
||||
setCustomerSessionToken(null);
|
||||
if (!open) {
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogTitle>Subscription created</DialogTitle>
|
||||
<DialogDescription>
|
||||
We have registered your subscription. It'll be activated within a
|
||||
couple of seconds.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>OK</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutButton({
|
||||
price,
|
||||
organization,
|
||||
projectId,
|
||||
disabled,
|
||||
}: {
|
||||
price: IPolarPrice;
|
||||
organization: IServiceOrganization;
|
||||
projectId: string;
|
||||
disabled?: string | null;
|
||||
}) {
|
||||
const isCurrentPrice = organization.subscriptionPriceId === price.id;
|
||||
const checkout = api.subscription.checkout.useMutation({
|
||||
onSuccess(data) {
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
toast.success('Subscription updated', {
|
||||
description: 'It might take a few seconds to update',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isCanceled =
|
||||
organization.subscriptionStatus === 'active' &&
|
||||
isCurrentPrice &&
|
||||
organization.subscriptionCanceledAt;
|
||||
const isActive =
|
||||
organization.subscriptionStatus === 'active' && isCurrentPrice;
|
||||
|
||||
return (
|
||||
<Tooltiper
|
||||
content={disabled}
|
||||
tooltipClassName="max-w-xs"
|
||||
side="left"
|
||||
disabled={!disabled}
|
||||
>
|
||||
<Button
|
||||
disabled={disabled !== null || (isActive && !isCanceled)}
|
||||
key={price.id}
|
||||
onClick={() => {
|
||||
checkout.mutate({
|
||||
projectId,
|
||||
organizationId: organization.id,
|
||||
productPriceId: price!.id,
|
||||
productId: price.productId,
|
||||
});
|
||||
}}
|
||||
loading={checkout.isLoading}
|
||||
className="w-28"
|
||||
variant={isActive ? 'outline' : 'default'}
|
||||
>
|
||||
{isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate'}
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { showConfirm } from '@/modals';
|
||||
import Confirm from '@/modals/Confirm';
|
||||
import { api } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import type { IPolarPrice } from '@openpanel/payments';
|
||||
import { format } from 'date-fns';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { product } from 'ramda';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Props = {
|
||||
organization: IServiceOrganization;
|
||||
};
|
||||
|
||||
export default function CurrentSubscription({ organization }: Props) {
|
||||
const router = useRouter();
|
||||
const { projectId } = useAppParams();
|
||||
const number = useNumber();
|
||||
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
|
||||
'customer_session_token',
|
||||
);
|
||||
const productQuery = api.subscription.getCurrent.useQuery({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
const cancelSubscription = api.subscription.cancelSubscription.useMutation({
|
||||
onSuccess(res) {
|
||||
toast.success('Subscription cancelled', {
|
||||
description: 'It might take a few seconds to update',
|
||||
});
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const portalMutation = api.subscription.portal.useMutation({
|
||||
onSuccess(data) {
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
},
|
||||
});
|
||||
const checkout = api.subscription.checkout.useMutation({
|
||||
onSuccess(data) {
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
toast.success('Subscription updated', {
|
||||
description: 'It might take a few seconds to update',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useWS(`/live/organization/${organization.id}`, () => {
|
||||
productQuery.refetch();
|
||||
});
|
||||
|
||||
function render() {
|
||||
if (productQuery.isLoading) {
|
||||
return (
|
||||
<div className="center-center p-8">
|
||||
<Loader2Icon className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (productQuery.isError) {
|
||||
return (
|
||||
<div className="center-center p-8 font-medium">
|
||||
Issues loading all tiers
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productQuery.data) {
|
||||
return (
|
||||
<div className="center-center p-8 font-medium">
|
||||
No subscription found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const product = productQuery.data;
|
||||
const price = product.prices[0]!;
|
||||
return (
|
||||
<>
|
||||
<div className="gap-4 col">
|
||||
<div className="row justify-between">
|
||||
<div>Name</div>
|
||||
<div className="text-right font-medium">{product.name}</div>
|
||||
</div>
|
||||
{price.amountType === 'fixed' ? (
|
||||
<>
|
||||
<div className="row justify-between">
|
||||
<div>Price</div>
|
||||
<div className="text-right font-medium font-mono">
|
||||
{number.currency(price.priceAmount / 100)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="row justify-between">
|
||||
<div>Price</div>
|
||||
<div className="text-right font-medium font-mono">FREE</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="row justify-between">
|
||||
<div>Billing Cycle</div>
|
||||
<div className="text-right font-medium">
|
||||
{price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'}
|
||||
</div>
|
||||
</div>
|
||||
{typeof product.metadata.eventsLimit === 'number' && (
|
||||
<div className="row justify-between">
|
||||
<div>Events per mount</div>
|
||||
<div className="text-right font-medium font-mono">
|
||||
{number.format(product.metadata.eventsLimit)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col gap-2">
|
||||
{organization.isWillBeCanceled || organization.isCanceled ? (
|
||||
<Button
|
||||
loading={checkout.isLoading}
|
||||
onClick={() => {
|
||||
checkout.mutate({
|
||||
projectId,
|
||||
organizationId: organization.id,
|
||||
productPriceId: price!.id,
|
||||
productId: price.productId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reactivate subscription
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={cancelSubscription.isLoading}
|
||||
onClick={() => {
|
||||
showConfirm({
|
||||
title: 'Cancel subscription',
|
||||
text: 'Are you sure you want to cancel your subscription?',
|
||||
onConfirm() {
|
||||
cancelSubscription.mutate({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-2 md:w-72 shrink-0">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Current Subscription</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
|
||||
organization.isExceeded ||
|
||||
organization.isExpired ||
|
||||
(organization.subscriptionStatus !== 'active' &&
|
||||
'bg-destructive'),
|
||||
organization.isWillBeCanceled && 'bg-orange-400',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
|
||||
organization.isExceeded ||
|
||||
organization.isExpired ||
|
||||
(organization.subscriptionStatus !== 'active' &&
|
||||
'bg-destructive'),
|
||||
organization.isWillBeCanceled && 'bg-orange-400',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="col gap-8">
|
||||
{organization.isTrial && organization.subscriptionEndsAt && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Free trial</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your organization is on a free trial. It ends on{' '}
|
||||
{format(organization.subscriptionEndsAt, 'PPP')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{organization.isExpired && organization.subscriptionEndsAt && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Subscription expired</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your subscription has expired. You can reactivate it by choosing
|
||||
a new plan below.
|
||||
</AlertDescription>
|
||||
<AlertDescription>
|
||||
It expired on {format(organization.subscriptionEndsAt, 'PPP')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{organization.isWillBeCanceled && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Subscription canceled</AlertTitle>
|
||||
<AlertDescription>
|
||||
You have canceled your subscription. You can reactivate it by
|
||||
choosing a new plan below.
|
||||
</AlertDescription>
|
||||
<AlertDescription className="font-medium">
|
||||
It'll expire on{' '}
|
||||
{format(organization.subscriptionEndsAt!, 'PPP')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{organization.isCanceled && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Subscription canceled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your subscription was canceled on{' '}
|
||||
{format(organization.subscriptionCanceledAt!, 'PPP')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{render()}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
{organization.hasSubscription && (
|
||||
<button
|
||||
className="text-center mt-2 w-2/3 hover:underline self-center"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
portalMutation.mutate({
|
||||
organizationId: organization.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Manage your subscription with
|
||||
<span className="font-medium ml-1">Polar Customer Portal</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { getOrganizationBySlug } from '@openpanel/db';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
|
||||
const validator = z.object({
|
||||
id: z.string().min(2),
|
||||
@@ -18,7 +18,7 @@ const validator = z.object({
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
interface EditOrganizationProps {
|
||||
organization: Awaited<ReturnType<typeof getOrganizationBySlug>>;
|
||||
organization: IServiceOrganization;
|
||||
}
|
||||
export default function EditOrganization({
|
||||
organization,
|
||||
@@ -41,29 +41,27 @@ export default function EditOrganization({
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-screen-sm">
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Details</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex items-end gap-2">
|
||||
<InputWithLabel
|
||||
className="flex-1"
|
||||
label="Name"
|
||||
{...register('name')}
|
||||
defaultValue={organization?.name}
|
||||
/>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
</section>
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Details</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex items-end gap-2">
|
||||
<InputWithLabel
|
||||
className="flex-1"
|
||||
label="Name"
|
||||
{...register('name')}
|
||||
defaultValue={organization?.name}
|
||||
/>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import EditOrganization from './edit-organization';
|
||||
|
||||
interface OrganizationProps {
|
||||
organization: IServiceOrganization;
|
||||
}
|
||||
export default function Organization({ organization }: OrganizationProps) {
|
||||
return (
|
||||
<section className="max-w-screen-sm col gap-8">
|
||||
<EditOrganization organization={organization} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useXAxisProps,
|
||||
useYAxisProps,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { api } from '@/trpc/client';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { sum } from '@openpanel/common';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartTooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type Props = {
|
||||
organization: IServiceOrganization;
|
||||
};
|
||||
|
||||
function Card({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
|
||||
<div className="text-muted-foreground truncate">{title}</div>
|
||||
<div className="font-mono text-xl font-bold truncate">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Usage({ organization }: Props) {
|
||||
const number = useNumber();
|
||||
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||
const yAxisProps = useYAxisProps({});
|
||||
const usageQuery = api.subscription.usage.useQuery({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const wrapper = (node: React.ReactNode) => (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Usage</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>{node}</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
if (usageQuery.isLoading) {
|
||||
return wrapper(
|
||||
<div className="center-center p-8">
|
||||
<Loader2Icon className="animate-spin" />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
if (usageQuery.isError) {
|
||||
return wrapper(
|
||||
<div className="center-center p-8 font-medium">
|
||||
Issues loading usage data
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionPeriodEventsLimit = organization.hasSubscription
|
||||
? organization.subscriptionPeriodEventsLimit
|
||||
: 0;
|
||||
const subscriptionPeriodEventsCount = organization.hasSubscription
|
||||
? organization.subscriptionPeriodEventsCount
|
||||
: 0;
|
||||
|
||||
const domain = [
|
||||
0,
|
||||
Math.max(
|
||||
subscriptionPeriodEventsLimit,
|
||||
subscriptionPeriodEventsCount,
|
||||
...usageQuery.data.map((item) => item.count),
|
||||
),
|
||||
] as [number, number];
|
||||
|
||||
domain[1] += domain[1] * 0.05;
|
||||
|
||||
return wrapper(
|
||||
<>
|
||||
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
|
||||
{organization.hasSubscription ? (
|
||||
<>
|
||||
<Card
|
||||
title="Period"
|
||||
value={
|
||||
organization.subscriptionCurrentPeriodStart &&
|
||||
organization.subscriptionCurrentPeriodEnd
|
||||
? `${formatDate(organization.subscriptionCurrentPeriodStart)}-${formatDate(organization.subscriptionCurrentPeriodEnd)}`
|
||||
: '🤷♂️'
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Limit"
|
||||
value={number.format(subscriptionPeriodEventsLimit)}
|
||||
/>
|
||||
<Card
|
||||
title="Events count"
|
||||
value={number.format(subscriptionPeriodEventsCount)}
|
||||
/>
|
||||
<Card
|
||||
title="Left to use"
|
||||
value={
|
||||
subscriptionPeriodEventsLimit === 0
|
||||
? '👀'
|
||||
: number.formatWithUnit(
|
||||
(1 -
|
||||
subscriptionPeriodEventsCount /
|
||||
subscriptionPeriodEventsLimit) *
|
||||
100,
|
||||
'%',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<Card title="Subscription" value={'No active subscription'} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Card
|
||||
title="Events from last 30 days"
|
||||
value={number.format(
|
||||
sum(usageQuery.data.map((item) => item.count)),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||
<ResponsiveContainer>
|
||||
<BarChart
|
||||
data={usageQuery.data.map((item) => ({
|
||||
date: new Date(item.day).getTime(),
|
||||
count: item.count,
|
||||
limit: subscriptionPeriodEventsLimit,
|
||||
total: subscriptionPeriodEventsCount,
|
||||
}))}
|
||||
barSize={8}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="usage" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<RechartTooltip
|
||||
content={<Tooltip />}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--def-400))',
|
||||
fill: 'hsl(var(--def-200))',
|
||||
}}
|
||||
/>
|
||||
{organization.hasSubscription && (
|
||||
<>
|
||||
<ReferenceLine
|
||||
y={subscriptionPeriodEventsLimit}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
|
||||
fill: getChartColor(1),
|
||||
position: 'insideTopRight',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={subscriptionPeriodEventsCount}
|
||||
stroke={getChartColor(2)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Your events count (${number.format(subscriptionPeriodEventsCount)})`,
|
||||
fill: getChartColor(2),
|
||||
position:
|
||||
subscriptionPeriodEventsCount > 1000
|
||||
? 'insideTop'
|
||||
: 'insideBottom',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Bar
|
||||
dataKey="count"
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={0.5}
|
||||
fill={'url(#usage)'}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<XAxis {...xAxisProps} dataKey="date" />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={domain}
|
||||
interval={0}
|
||||
ticks={[
|
||||
0,
|
||||
subscriptionPeriodEventsLimit * 0.25,
|
||||
subscriptionPeriodEventsLimit * 0.5,
|
||||
subscriptionPeriodEventsLimit * 0.75,
|
||||
subscriptionPeriodEventsLimit,
|
||||
]}
|
||||
/>
|
||||
<CartesianGrid
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip(props: any) {
|
||||
const number = useNumber();
|
||||
const payload = props.payload?.[0]?.payload;
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(payload.date)}
|
||||
</div>
|
||||
{payload.limit !== 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
|
||||
<div className="col gap-1">
|
||||
<div className="text-sm text-muted-foreground">Your tier limit</div>
|
||||
<div className="text-lg font-semibold text-chart-1">
|
||||
{number.format(payload.limit)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{payload.total !== 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-2" />
|
||||
<div className="col gap-1">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Total events count
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-chart-2">
|
||||
{number.format(payload.total)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-1 rounded-full bg-chart-0" />
|
||||
<div className="col gap-1">
|
||||
<div className="text-sm text-muted-foreground">Events this day</div>
|
||||
<div className="text-lg font-semibold text-chart-0">
|
||||
{number.format(payload.count)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import { notFound } from 'next/navigation';
|
||||
import { parseAsStringEnum } from 'nuqs/server';
|
||||
|
||||
import { auth } from '@openpanel/auth/nextjs';
|
||||
import { db } from '@openpanel/db';
|
||||
import { db, transformOrganization } from '@openpanel/db';
|
||||
|
||||
import EditOrganization from './edit-organization';
|
||||
import InvitesServer from './invites';
|
||||
import MembersServer from './members';
|
||||
import Billing from './organization/billing';
|
||||
import { BillingFaq } from './organization/billing-faq';
|
||||
import CurrentSubscription from './organization/current-subscription';
|
||||
import Organization from './organization/organization';
|
||||
import Usage from './organization/usage';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -23,7 +27,8 @@ export default async function Page({
|
||||
params: { organizationSlug: organizationId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const tab = parseAsStringEnum(['org', 'members', 'invites'])
|
||||
const isBillingEnabled = !process.env.SELF_HOSTED;
|
||||
const tab = parseAsStringEnum(['org', 'billing', 'members', 'invites'])
|
||||
.withDefault('org')
|
||||
.parseServerSide(searchParams.tab);
|
||||
const session = await auth();
|
||||
@@ -71,6 +76,11 @@ export default async function Page({
|
||||
<PageTabsLink href={'?tab=org'} isActive={tab === 'org'}>
|
||||
Organization
|
||||
</PageTabsLink>
|
||||
{isBillingEnabled && (
|
||||
<PageTabsLink href={'?tab=billing'} isActive={tab === 'billing'}>
|
||||
Billing
|
||||
</PageTabsLink>
|
||||
)}
|
||||
<PageTabsLink href={'?tab=members'} isActive={tab === 'members'}>
|
||||
Members
|
||||
</PageTabsLink>
|
||||
@@ -79,7 +89,17 @@ export default async function Page({
|
||||
</PageTabsLink>
|
||||
</PageTabs>
|
||||
|
||||
{tab === 'org' && <EditOrganization organization={organization} />}
|
||||
{tab === 'org' && <Organization organization={organization} />}
|
||||
{tab === 'billing' && isBillingEnabled && (
|
||||
<div className="flex flex-col-reverse md:flex-row gap-8 max-w-screen-lg">
|
||||
<div className="col gap-8 w-full">
|
||||
<Billing organization={organization} />
|
||||
<Usage organization={organization} />
|
||||
<BillingFaq />
|
||||
</div>
|
||||
<CurrentSubscription organization={organization} />
|
||||
</div>
|
||||
)}
|
||||
{tab === 'members' && <MembersServer organizationId={organizationId} />}
|
||||
{tab === 'invites' && <InvitesServer organizationId={organizationId} />}
|
||||
</Padding>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { showConfirm } from '@/modals';
|
||||
import { api, handleError } from '@/trpc/client';
|
||||
import type { IServiceProjectWithClients } from '@openpanel/db';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { router } from '@trpc/server';
|
||||
import { addHours, format, startOfHour } from 'date-fns';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Props = { project: IServiceProjectWithClients };
|
||||
|
||||
export default function DeleteProject({ project }: Props) {
|
||||
const router = useRouter();
|
||||
const mutation = api.project.delete.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess: () => {
|
||||
toast.success('Project updated');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
const cancelDeletionMutation = api.project.cancelDeletion.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess: () => {
|
||||
toast.success('Project updated');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Delete Project</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="col gap-4">
|
||||
<p>
|
||||
Deleting your project will remove it from your organization and all of
|
||||
its data. It'll be permanently deleted after 24 hours.
|
||||
</p>
|
||||
{project?.deleteAt && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Project scheduled for deletion</AlertTitle>
|
||||
<AlertDescription>
|
||||
This project will be deleted on{' '}
|
||||
<span className="font-medium">
|
||||
{
|
||||
// add 1 hour and round to the nearest hour
|
||||
// Since we run cron once an hour
|
||||
format(
|
||||
startOfHour(addHours(project.deleteAt, 1)),
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
)
|
||||
}
|
||||
</span>
|
||||
. Any event associated with this project will be deleted.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="row gap-4 justify-end">
|
||||
{project?.deleteAt && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
cancelDeletionMutation.mutate({ projectId: project.id });
|
||||
}}
|
||||
>
|
||||
Cancel deletion
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!!project?.deleteAt}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
showConfirm({
|
||||
title: 'Delete Project',
|
||||
text: 'Are you sure you want to delete this project?',
|
||||
onConfirm: () => {
|
||||
mutation.mutate({ projectId: project.id });
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@openpanel/db';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import DeleteProject from './delete-project';
|
||||
import EditProjectDetails from './edit-project-details';
|
||||
import EditProjectFilters from './edit-project-filters';
|
||||
import ProjectClients from './project-clients';
|
||||
@@ -34,6 +35,7 @@ export default async function Page({ params: { projectId } }: PageProps) {
|
||||
<EditProjectDetails project={project} />
|
||||
<EditProjectFilters project={project} />
|
||||
<ProjectClients project={project} />
|
||||
<DeleteProject project={project} />
|
||||
</div>
|
||||
</Padding>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function PageTabs({
|
||||
children,
|
||||
@@ -27,15 +31,23 @@ export function PageTabsLink({
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'inline-block opacity-100 transition-transform hover:translate-y-[-1px]',
|
||||
isActive ? 'opacity-100' : 'opacity-50',
|
||||
<div className="relative">
|
||||
<Link
|
||||
className={cn(
|
||||
'inline-block opacity-100 transition-transform hover:translate-y-[-1px]',
|
||||
isActive ? 'opacity-100' : 'opacity-50',
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="rounded-full absolute -bottom-1 left-0 right-0 h-0.5 bg-primary"
|
||||
layoutId={'page-tabs-link'}
|
||||
/>
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ export function Chart({ data }: Props) {
|
||||
}, [series]);
|
||||
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: [data.metrics.max],
|
||||
hide: hideYAxis,
|
||||
});
|
||||
const xAxisProps = useXAxisProps({
|
||||
|
||||
@@ -22,12 +22,7 @@ export function getYAxisWidth(value: string | undefined | null) {
|
||||
return charLength * value.length + charLength;
|
||||
}
|
||||
|
||||
export const useYAxisProps = ({
|
||||
data,
|
||||
hide,
|
||||
tickFormatter,
|
||||
}: {
|
||||
data: number[];
|
||||
export const useYAxisProps = (options?: {
|
||||
hide?: boolean;
|
||||
tickFormatter?: (value: number) => string;
|
||||
}) => {
|
||||
@@ -38,12 +33,14 @@ export const useYAxisProps = ({
|
||||
|
||||
return {
|
||||
...AXIS_FONT_PROPS,
|
||||
width: hide ? 0 : width,
|
||||
width: options?.hide ? 0 : width,
|
||||
axisLine: false,
|
||||
tickLine: false,
|
||||
allowDecimals: false,
|
||||
tickFormatter: (value: number) => {
|
||||
const tick = tickFormatter ? tickFormatter(value) : number.short(value);
|
||||
const tick = options?.tickFormatter
|
||||
? options.tickFormatter(value)
|
||||
: number.short(value);
|
||||
const newWidth = getYAxisWidth(tick);
|
||||
ref.current.push(newWidth);
|
||||
setWidthDebounced(Math.max(...ref.current));
|
||||
|
||||
@@ -49,7 +49,6 @@ export function Chart({ data }: Props) {
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: [data.metrics.max],
|
||||
hide: hideYAxis,
|
||||
});
|
||||
const xAxisProps = useXAxisProps({
|
||||
|
||||
@@ -110,7 +110,6 @@ export function Chart({ data }: Props) {
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: [data.metrics.max],
|
||||
hide: hideYAxis,
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { average, round } from '@openpanel/common';
|
||||
import { fix } from 'mathjs';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { RetentionTooltip } from './tooltip';
|
||||
@@ -33,7 +32,6 @@ export function Chart({ data }: Props) {
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: [100],
|
||||
hide: hideYAxis,
|
||||
tickFormatter: (value) => `${value}%`,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const AccordionItem = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b', className)}
|
||||
className={cn('border-b [&:last-child]:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -11,6 +11,8 @@ const alertVariants = cva(
|
||||
default: 'bg-card text-foreground',
|
||||
destructive:
|
||||
'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
warning:
|
||||
'bg-orange-400/10 border-orange-400 text-orange-600 dark:border-orange-400 [&>svg]:text-orange-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -10,12 +10,12 @@ import Link from 'next/link';
|
||||
import * as React from 'react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:translate-y-[-1px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
cta: 'bg-highlight text-white hover:bg-highlight',
|
||||
cta: 'bg-highlight text-white hover:bg-highlight/80',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
|
||||
@@ -86,10 +86,7 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
className={cn('text-lg font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -101,7 +98,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn(' text-muted-foreground', className)}
|
||||
className={cn(' text-muted-foreground mt-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -4,6 +4,7 @@ interface Props<T> {
|
||||
columns: {
|
||||
name: string;
|
||||
render: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
}[];
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
@@ -41,7 +42,9 @@ export function WidgetTable<T>({
|
||||
<WidgetTableHead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.name}>{column.name}</th>
|
||||
<th key={column.name} className={cn(column.className)}>
|
||||
{column.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</WidgetTableHead>
|
||||
@@ -49,10 +52,14 @@ export function WidgetTable<T>({
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4"
|
||||
className={
|
||||
'border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4'
|
||||
}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.name}>{column.render(item)}</td>
|
||||
<td key={column.name} className={cn(column.className)}>
|
||||
{column.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -26,11 +26,25 @@ export const shortNumber =
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export const formatCurrency =
|
||||
(locale: string) =>
|
||||
(amount: number, currency = 'USD') => {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
export function useNumber() {
|
||||
const locale = 'en-gb';
|
||||
const locale = 'en-US';
|
||||
const format = formatNumber(locale);
|
||||
const short = shortNumber(locale);
|
||||
const currency = formatCurrency(locale);
|
||||
|
||||
return {
|
||||
currency,
|
||||
format,
|
||||
short,
|
||||
shortWithUnit: (value: number | null | undefined, unit?: string | null) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import debounce from 'lodash.debounce';
|
||||
import { use, useEffect, useMemo, useState } from 'react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
import { getSuperJson } from '@openpanel/common';
|
||||
import { getSuperJson } from '@openpanel/json';
|
||||
|
||||
type UseWSOptions = {
|
||||
debounce?: {
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Confirm({
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title={title} />
|
||||
<p>{text}</p>
|
||||
<p className="text-lg -mt-2">{text}</p>
|
||||
<ButtonContainer>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
|
||||
@@ -14,8 +15,6 @@ const PRICING = [
|
||||
{ price: 180, events: 2_500_000 },
|
||||
{ price: 250, events: 5_000_000 },
|
||||
{ price: 400, events: 10_000_000 },
|
||||
// { price: 650, events: 20_000_000 },
|
||||
// { price: 900, events: 30_000_000 },
|
||||
];
|
||||
|
||||
export function PricingSlider() {
|
||||
@@ -31,7 +30,7 @@ export function PricingSlider() {
|
||||
step={1}
|
||||
tooltip={
|
||||
match
|
||||
? `${formatNumber(match.events)} events`
|
||||
? `${formatNumber(match.events)} events per month`
|
||||
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
|
||||
}
|
||||
onValueChange={(value) => setIndex(value[0])}
|
||||
@@ -39,20 +38,36 @@ export function PricingSlider() {
|
||||
|
||||
{match ? (
|
||||
<div>
|
||||
<NumberFlow
|
||||
className="text-5xl"
|
||||
value={match.price}
|
||||
format={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}}
|
||||
locales={'en-US'}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground ml-2">/ month</span>
|
||||
<div>
|
||||
<NumberFlow
|
||||
className="text-5xl"
|
||||
value={match.price}
|
||||
format={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}}
|
||||
locales={'en-US'}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground ml-2">/ month</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground italic opacity-100',
|
||||
match.price === 0 && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
+ VAT if applicable
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>Contact us hello@openpanel.dev</div>
|
||||
<div className="text-lg">
|
||||
Contact us at{' '}
|
||||
<a className="underline" href="mailto:hello@openpanel.dev">
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
to get a custom quote.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function Pricing() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col justify-between pt-14">
|
||||
<div className="col justify-between pt-14 gap-4">
|
||||
<PricingSlider />
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -27,6 +27,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
# Packages
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/json/package.json ./packages/json/
|
||||
COPY packages/redis/package.json ./packages/redis/
|
||||
COPY packages/queue/package.json ./packages/queue/
|
||||
COPY packages/logger/package.json ./packages/logger/
|
||||
@@ -70,6 +71,7 @@ COPY --from=build /app/apps/worker ./apps/worker
|
||||
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db ./packages/db
|
||||
COPY --from=build /app/packages/json ./packages/json
|
||||
COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/queue ./packages/queue
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function bootCron() {
|
||||
type: 'salt',
|
||||
pattern: '0 0 * * *',
|
||||
},
|
||||
{
|
||||
name: 'deleteProjects',
|
||||
type: 'deleteProjects',
|
||||
pattern: '0 * * * *',
|
||||
},
|
||||
{
|
||||
name: 'flush',
|
||||
type: 'flushEvents',
|
||||
|
||||
34
apps/worker/src/jobs/cron.delete-projects.ts
Normal file
34
apps/worker/src/jobs/cron.delete-projects.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { logger } from '@/utils/logger';
|
||||
import { generateSalt } from '@openpanel/common/server';
|
||||
import { TABLE_NAMES, ch, chQuery, db } from '@openpanel/db';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
export async function deleteProjects() {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
deleteAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
await db.project.delete({
|
||||
where: {
|
||||
id: project.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await ch.command({
|
||||
query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
||||
});
|
||||
|
||||
logger.info(`Deleted ${projects.length} projects`, {
|
||||
projects,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Job } from 'bullmq';
|
||||
import { eventBuffer, profileBuffer } from '@openpanel/db';
|
||||
import type { CronQueuePayload } from '@openpanel/queue';
|
||||
|
||||
import { deleteProjects } from './cron.delete-projects';
|
||||
import { ping } from './cron.ping';
|
||||
import { salt } from './cron.salt';
|
||||
|
||||
@@ -20,5 +21,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
||||
case 'ping': {
|
||||
return await ping();
|
||||
}
|
||||
case 'deleteProjects': {
|
||||
return await deleteProjects();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,12 @@
|
||||
import type { Job } from 'bullmq';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { TABLE_NAMES, chQuery, db } from '@openpanel/db';
|
||||
import type {
|
||||
EventsQueuePayload,
|
||||
EventsQueuePayloadCreateSessionEnd,
|
||||
EventsQueuePayloadIncomingEvent,
|
||||
} from '@openpanel/queue';
|
||||
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { createSessionEnd } from './events.create-session-end';
|
||||
import { incomingEvent } from './events.incoming-event';
|
||||
|
||||
export async function eventsJob(job: Job<EventsQueuePayload>) {
|
||||
switch (job.data.type) {
|
||||
case 'incomingEvent': {
|
||||
return await incomingEvent(job as Job<EventsQueuePayloadIncomingEvent>);
|
||||
}
|
||||
case 'createSessionEnd': {
|
||||
try {
|
||||
await updateEventsCount(job.data.payload.projectId);
|
||||
} catch (e) {
|
||||
job.log('Failed to update count');
|
||||
}
|
||||
|
||||
return await createSessionEnd(
|
||||
job as Job<EventsQueuePayloadCreateSessionEnd>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getProjectEventsCount = cacheable(async function getProjectEventsCount(
|
||||
projectId: string,
|
||||
) {
|
||||
const res = await chQuery<{ count: number }>(
|
||||
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)}`,
|
||||
);
|
||||
return res[0]?.count;
|
||||
}, 60 * 60);
|
||||
|
||||
async function updateEventsCount(projectId: string) {
|
||||
const count = await getProjectEventsCount(projectId);
|
||||
if (count) {
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
data: {
|
||||
eventsCount: count,
|
||||
},
|
||||
});
|
||||
}
|
||||
return await incomingEvent(job as Job<EventsQueuePayloadIncomingEvent>);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Job } from 'bullmq';
|
||||
|
||||
import { setSuperJson } from '@openpanel/common';
|
||||
import { db } from '@openpanel/db';
|
||||
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
||||
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import type { NotificationQueuePayload } from '@openpanel/queue';
|
||||
import { getRedisPub } from '@openpanel/redis';
|
||||
import { getRedisPub, publishEvent } from '@openpanel/redis';
|
||||
|
||||
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
||||
switch (job.data.type) {
|
||||
@@ -13,7 +13,7 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
||||
const { notification } = job.data.payload;
|
||||
|
||||
if (notification.sendToApp) {
|
||||
getRedisPub().publish('notification', setSuperJson(notification));
|
||||
publishEvent('notification', 'created', notification);
|
||||
// empty for now
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,79 @@ import type { Job } from 'bullmq';
|
||||
|
||||
import type { SessionsQueuePayload } from '@openpanel/queue';
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
db,
|
||||
getOrganizationBillingEventsCount,
|
||||
getOrganizationByProjectIdCached,
|
||||
getProjectEventsCount,
|
||||
} from '@openpanel/db';
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { createSessionEnd } from './events.create-session-end';
|
||||
|
||||
export async function sessionsJob(job: Job<SessionsQueuePayload>) {
|
||||
return await createSessionEnd(job);
|
||||
const res = await createSessionEnd(job);
|
||||
try {
|
||||
await updateEventsCount(job.data.payload.projectId);
|
||||
} catch (e) {
|
||||
logger.error('Failed to update events count', e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const updateEventsCount = cacheable(async function updateEventsCount(
|
||||
projectId: string,
|
||||
) {
|
||||
const organization = await db.organization.findFirst({
|
||||
where: {
|
||||
projects: {
|
||||
some: {
|
||||
id: projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
projects: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organizationEventsCount =
|
||||
await getOrganizationBillingEventsCount(organization);
|
||||
const projectEventsCount = await getProjectEventsCount(projectId);
|
||||
|
||||
if (projectEventsCount) {
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
data: {
|
||||
eventsCount: projectEventsCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (organizationEventsCount) {
|
||||
await db.organization.update({
|
||||
where: {
|
||||
id: organization.id,
|
||||
},
|
||||
data: {
|
||||
subscriptionPeriodEventsCount: organizationEventsCount,
|
||||
subscriptionPeriodEventsCountExceededAt:
|
||||
organizationEventsCount >
|
||||
organization.subscriptionPeriodEventsLimit &&
|
||||
!organization.subscriptionPeriodEventsCountExceededAt
|
||||
? new Date()
|
||||
: organizationEventsCount <=
|
||||
organization.subscriptionPeriodEventsLimit
|
||||
? null
|
||||
: organization.subscriptionPeriodEventsCountExceededAt,
|
||||
},
|
||||
});
|
||||
await getOrganizationByProjectIdCached.clear(projectId);
|
||||
}
|
||||
}, 60 * 60);
|
||||
|
||||
Reference in New Issue
Block a user