feat: add manage api for projects, clients and references
This commit is contained in:
649
apps/api/src/controllers/manage.controller.ts
Normal file
649
apps/api/src/controllers/manage.controller.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
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';
|
||||
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
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,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user