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 }>, 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; }>, 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 }>, 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; }>, 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 }>, 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; }>, 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 }); }