* fix: how we fetch profiles in the buffer * perf: optimize event buffer * remove unused file * fix * wip * wip: try groupmq 2 * try simplified event buffer with duration calculation on the fly instead
644 lines
15 KiB
TypeScript
644 lines
15 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import { stripTrailingSlash } from '@openpanel/common';
|
|
import { hashPassword } from '@openpanel/common/server';
|
|
import {
|
|
db,
|
|
getClientByIdCached,
|
|
getId,
|
|
getProjectByIdCached,
|
|
} from '@openpanel/db';
|
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { HttpError } from '@/utils/errors';
|
|
|
|
// Validation schemas
|
|
const zCreateProject = z.object({
|
|
name: z.string().min(1),
|
|
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
|
cors: z.array(z.string()).default([]),
|
|
crossDomain: z.boolean().optional().default(false),
|
|
types: z
|
|
.array(z.enum(['website', 'app', 'backend']))
|
|
.optional()
|
|
.default([]),
|
|
});
|
|
|
|
const zUpdateProject = z.object({
|
|
name: z.string().min(1).optional(),
|
|
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
|
cors: z.array(z.string()).optional(),
|
|
crossDomain: z.boolean().optional(),
|
|
allowUnsafeRevenueTracking: z.boolean().optional(),
|
|
});
|
|
|
|
const zCreateClient = z.object({
|
|
name: z.string().min(1),
|
|
projectId: z.string().optional(),
|
|
type: z.enum(['read', 'write', 'root']).optional().default('write'),
|
|
});
|
|
|
|
const zUpdateClient = z.object({
|
|
name: z.string().min(1).optional(),
|
|
});
|
|
|
|
const zCreateReference = z.object({
|
|
projectId: z.string(),
|
|
title: z.string().min(1),
|
|
description: z.string().optional(),
|
|
datetime: z.string(),
|
|
});
|
|
|
|
const zUpdateReference = z.object({
|
|
title: z.string().min(1).optional(),
|
|
description: z.string().optional(),
|
|
datetime: z.string().optional(),
|
|
});
|
|
|
|
// Projects CRUD
|
|
export async function listProjects(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply
|
|
) {
|
|
const projects = await db.project.findMany({
|
|
where: {
|
|
organizationId: request.client!.organizationId,
|
|
deleteAt: null,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
|
|
reply.send({ data: projects });
|
|
}
|
|
|
|
export async function getProject(
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const project = await db.project.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
|
|
reply.send({ data: project });
|
|
}
|
|
|
|
export async function createProject(
|
|
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const parsed = zCreateProject.safeParse(request.body);
|
|
|
|
if (parsed.success === false) {
|
|
return reply.status(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: parsed.error.errors,
|
|
});
|
|
}
|
|
|
|
const { name, domain, cors, crossDomain, types } = parsed.data;
|
|
|
|
// Generate a default client secret
|
|
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
|
const clientData = {
|
|
organizationId: request.client!.organizationId,
|
|
name: 'First client',
|
|
type: 'write' as const,
|
|
secret: await hashPassword(secret),
|
|
};
|
|
|
|
const project = await db.project.create({
|
|
data: {
|
|
id: await getId('project', name),
|
|
organizationId: request.client!.organizationId,
|
|
name,
|
|
domain: domain ? stripTrailingSlash(domain) : null,
|
|
cors: cors.map((c) => stripTrailingSlash(c)),
|
|
crossDomain: crossDomain ?? false,
|
|
allowUnsafeRevenueTracking: false,
|
|
filters: [],
|
|
types,
|
|
clients: {
|
|
create: clientData,
|
|
},
|
|
},
|
|
include: {
|
|
clients: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await Promise.all([
|
|
getProjectByIdCached.clear(project.id),
|
|
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
|
|
]);
|
|
|
|
reply.send({
|
|
data: {
|
|
...project,
|
|
client: project.clients[0]
|
|
? {
|
|
id: project.clients[0].id,
|
|
secret,
|
|
}
|
|
: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updateProject(
|
|
request: FastifyRequest<{
|
|
Params: { id: string };
|
|
Body: z.infer<typeof zUpdateProject>;
|
|
}>,
|
|
reply: FastifyReply
|
|
) {
|
|
const parsed = zUpdateProject.safeParse(request.body);
|
|
|
|
if (parsed.success === false) {
|
|
return reply.status(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: parsed.error.errors,
|
|
});
|
|
}
|
|
|
|
// Verify project exists and belongs to organization
|
|
const existing = await db.project.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
include: {
|
|
clients: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
|
|
const updateData: any = {};
|
|
if (parsed.data.name !== undefined) {
|
|
updateData.name = parsed.data.name;
|
|
}
|
|
if (parsed.data.domain !== undefined) {
|
|
updateData.domain = parsed.data.domain
|
|
? stripTrailingSlash(parsed.data.domain)
|
|
: null;
|
|
}
|
|
if (parsed.data.cors !== undefined) {
|
|
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
|
|
}
|
|
if (parsed.data.crossDomain !== undefined) {
|
|
updateData.crossDomain = parsed.data.crossDomain;
|
|
}
|
|
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
|
|
updateData.allowUnsafeRevenueTracking =
|
|
parsed.data.allowUnsafeRevenueTracking;
|
|
}
|
|
|
|
const project = await db.project.update({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
data: updateData,
|
|
});
|
|
|
|
await Promise.all([
|
|
getProjectByIdCached.clear(project.id),
|
|
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
|
|
]);
|
|
|
|
reply.send({ data: project });
|
|
}
|
|
|
|
export async function deleteProject(
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const project = await db.project.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
|
|
await db.project.update({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
data: {
|
|
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
await getProjectByIdCached.clear(request.params.id);
|
|
|
|
reply.send({ success: true });
|
|
}
|
|
|
|
// Clients CRUD
|
|
export async function listClients(
|
|
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const where: any = {
|
|
organizationId: request.client!.organizationId,
|
|
};
|
|
|
|
if (request.query.projectId) {
|
|
// Verify project belongs to organization
|
|
const project = await db.project.findFirst({
|
|
where: {
|
|
id: request.query.projectId,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
|
|
where.projectId = request.query.projectId;
|
|
}
|
|
|
|
const clients = await db.client.findMany({
|
|
where,
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
|
|
reply.send({ data: clients });
|
|
}
|
|
|
|
export async function getClient(
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const client = await db.client.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!client) {
|
|
throw new HttpError('Client not found', { status: 404 });
|
|
}
|
|
|
|
reply.send({ data: client });
|
|
}
|
|
|
|
export async function createClient(
|
|
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const parsed = zCreateClient.safeParse(request.body);
|
|
|
|
if (parsed.success === false) {
|
|
return reply.status(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: parsed.error.errors,
|
|
});
|
|
}
|
|
|
|
const { name, projectId, type } = parsed.data;
|
|
|
|
// If projectId is provided, verify it belongs to organization
|
|
if (projectId) {
|
|
const project = await db.project.findFirst({
|
|
where: {
|
|
id: projectId,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
}
|
|
|
|
// Generate secret
|
|
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
|
|
|
const client = await db.client.create({
|
|
data: {
|
|
organizationId: request.client!.organizationId,
|
|
projectId: projectId || null,
|
|
name,
|
|
type: type || 'write',
|
|
secret: await hashPassword(secret),
|
|
},
|
|
});
|
|
|
|
await getClientByIdCached.clear(client.id);
|
|
|
|
reply.send({
|
|
data: {
|
|
...client,
|
|
secret, // Return plain secret only once
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updateClient(
|
|
request: FastifyRequest<{
|
|
Params: { id: string };
|
|
Body: z.infer<typeof zUpdateClient>;
|
|
}>,
|
|
reply: FastifyReply
|
|
) {
|
|
const parsed = zUpdateClient.safeParse(request.body);
|
|
|
|
if (parsed.success === false) {
|
|
return reply.status(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: parsed.error.errors,
|
|
});
|
|
}
|
|
|
|
// Verify client exists and belongs to organization
|
|
const existing = await db.client.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new HttpError('Client not found', { status: 404 });
|
|
}
|
|
|
|
const updateData: any = {};
|
|
if (parsed.data.name !== undefined) {
|
|
updateData.name = parsed.data.name;
|
|
}
|
|
|
|
const client = await db.client.update({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
data: updateData,
|
|
});
|
|
|
|
await getClientByIdCached.clear(client.id);
|
|
|
|
reply.send({ data: client });
|
|
}
|
|
|
|
export async function deleteClient(
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const client = await db.client.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!client) {
|
|
throw new HttpError('Client not found', { status: 404 });
|
|
}
|
|
|
|
await db.client.delete({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
});
|
|
|
|
await getClientByIdCached.clear(request.params.id);
|
|
|
|
reply.send({ success: true });
|
|
}
|
|
|
|
// References CRUD
|
|
export async function listReferences(
|
|
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const where: any = {};
|
|
|
|
if (request.query.projectId) {
|
|
// Verify project belongs to organization
|
|
const project = await db.project.findFirst({
|
|
where: {
|
|
id: request.query.projectId,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
|
|
where.projectId = request.query.projectId;
|
|
} else {
|
|
// If no projectId, get all projects in org and filter references
|
|
const projects = await db.project.findMany({
|
|
where: {
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
where.projectId = {
|
|
in: projects.map((p) => p.id),
|
|
};
|
|
}
|
|
|
|
const references = await db.reference.findMany({
|
|
where,
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
|
|
reply.send({ data: references });
|
|
}
|
|
|
|
export async function getReference(
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const reference = await db.reference.findUnique({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {
|
|
organizationId: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!reference) {
|
|
throw new HttpError('Reference not found', { status: 404 });
|
|
}
|
|
|
|
if (reference.project.organizationId !== request.client!.organizationId) {
|
|
throw new HttpError('Reference not found', { status: 404 });
|
|
}
|
|
|
|
reply.send({ data: reference });
|
|
}
|
|
|
|
export async function createReference(
|
|
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const parsed = zCreateReference.safeParse(request.body);
|
|
|
|
if (parsed.success === false) {
|
|
return reply.status(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: parsed.error.errors,
|
|
});
|
|
}
|
|
|
|
const { projectId, title, description, datetime } = parsed.data;
|
|
|
|
// Verify project belongs to organization
|
|
const project = await db.project.findFirst({
|
|
where: {
|
|
id: projectId,
|
|
organizationId: request.client!.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpError('Project not found', { status: 404 });
|
|
}
|
|
|
|
const reference = await db.reference.create({
|
|
data: {
|
|
projectId,
|
|
title,
|
|
description: description || null,
|
|
date: new Date(datetime),
|
|
},
|
|
});
|
|
|
|
reply.send({ data: reference });
|
|
}
|
|
|
|
export async function updateReference(
|
|
request: FastifyRequest<{
|
|
Params: { id: string };
|
|
Body: z.infer<typeof zUpdateReference>;
|
|
}>,
|
|
reply: FastifyReply
|
|
) {
|
|
const parsed = zUpdateReference.safeParse(request.body);
|
|
|
|
if (parsed.success === false) {
|
|
return reply.status(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Invalid request body',
|
|
details: parsed.error.errors,
|
|
});
|
|
}
|
|
|
|
// Verify reference exists and belongs to organization
|
|
const existing = await db.reference.findUnique({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {
|
|
organizationId: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new HttpError('Reference not found', { status: 404 });
|
|
}
|
|
|
|
if (existing.project.organizationId !== request.client!.organizationId) {
|
|
throw new HttpError('Reference not found', { status: 404 });
|
|
}
|
|
|
|
const updateData: any = {};
|
|
if (parsed.data.title !== undefined) {
|
|
updateData.title = parsed.data.title;
|
|
}
|
|
if (parsed.data.description !== undefined) {
|
|
updateData.description = parsed.data.description ?? null;
|
|
}
|
|
if (parsed.data.datetime !== undefined) {
|
|
updateData.date = new Date(parsed.data.datetime);
|
|
}
|
|
|
|
const reference = await db.reference.update({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
data: updateData,
|
|
});
|
|
|
|
reply.send({ data: reference });
|
|
}
|
|
|
|
export async function deleteReference(
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) {
|
|
const reference = await db.reference.findUnique({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {
|
|
organizationId: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!reference) {
|
|
throw new HttpError('Reference not found', { status: 404 });
|
|
}
|
|
|
|
if (reference.project.organizationId !== request.client!.organizationId) {
|
|
throw new HttpError('Reference not found', { status: 404 });
|
|
}
|
|
|
|
await db.reference.delete({
|
|
where: {
|
|
id: request.params.id,
|
|
},
|
|
});
|
|
|
|
reply.send({ success: true });
|
|
}
|