Add docs API endpoints and database schema

- Introduced new API routes for managing documents, including listing, creating, updating, and deleting documents.
- Implemented database queries for document operations.
- Added validation schemas for request and response types using Zod.
- Updated the database schema to include a 'docs' table with necessary constraints.
- Integrated the new docs functionality into the server and shared packages.
This commit is contained in:
dal 2025-09-08 15:48:17 -06:00
parent cae94ae56a
commit d17c21b2b7
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
27 changed files with 7716 additions and 15 deletions

View File

@ -0,0 +1,44 @@
import type { User } from '@buster/database';
import { getUserOrganizationId, listDocs } from '@buster/database';
import type { GetDocsListRequest, GetDocsListResponse } from '@buster/server-shared/docs';
import { HTTPException } from 'hono/http-exception';
export async function listDocsHandler(
request: GetDocsListRequest,
user: User
): Promise<GetDocsListResponse> {
const { page, page_size, type, search } = request;
const userToOrg = await getUserOrganizationId(user.id);
if (!userToOrg) {
throw new HTTPException(403, {
message: 'User is not associated with an organization',
});
}
const result = await listDocs({
organizationId: userToOrg.organizationId,
type: type as 'analyst' | 'normal' | undefined,
search,
page,
pageSize: page_size,
});
return {
data: result.data.map((doc) => ({
id: doc.id,
name: doc.name,
content: doc.content,
type: doc.type as 'analyst' | 'normal',
organizationId: doc.organizationId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
deletedAt: doc.deletedAt,
})),
total: result.total,
page: result.page,
page_size: result.pageSize,
total_pages: result.totalPages,
};
}

View File

@ -0,0 +1,56 @@
import type { User } from '@buster/database';
import { getUserOrganizationId, upsertDoc } from '@buster/database';
import type { CreateDocRequest, CreateDocResponse } from '@buster/server-shared/docs';
import { HTTPException } from 'hono/http-exception';
export async function createDocHandler(
request: CreateDocRequest,
user: User
): Promise<CreateDocResponse> {
try {
const { name, content, type } = request;
const userToOrg = await getUserOrganizationId(user.id);
if (!userToOrg) {
throw new HTTPException(403, {
message: 'User is not associated with an organization',
});
}
if (userToOrg.role !== 'workspace_admin' && userToOrg.role !== 'data_admin') {
throw new HTTPException(403, {
message: 'User is not an admin',
});
}
const doc = await upsertDoc({
name,
content,
type,
organizationId: userToOrg.organizationId,
});
if (!doc) {
throw new HTTPException(400, {
message: 'Failed to create doc',
});
}
return {
id: doc.id,
name: doc.name,
content: doc.content,
type: doc.type as 'analyst' | 'normal',
organizationId: doc.organizationId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
deletedAt: doc.deletedAt,
};
} catch (error) {
console.error('Error creating doc:', error);
throw new HTTPException(500, {
message: 'Failed to create doc',
});
}
}

View File

@ -0,0 +1,34 @@
import type { User } from '@buster/database';
import { deleteDoc, getUserOrganizationId } from '@buster/database';
import type { DeleteDocResponse } from '@buster/server-shared/docs';
import { HTTPException } from 'hono/http-exception';
export async function deleteDocHandler(docId: string, user: User): Promise<DeleteDocResponse> {
const userToOrg = await getUserOrganizationId(user.id);
if (!userToOrg) {
throw new HTTPException(403, {
message: 'User is not associated with an organization',
});
}
if (userToOrg.role !== 'workspace_admin' && userToOrg.role !== 'data_admin') {
throw new HTTPException(403, {
message: 'User is not an admin',
});
}
const doc = await deleteDoc({
id: docId,
organizationId: userToOrg.organizationId,
});
if (!doc) {
throw new HTTPException(404, { message: 'Document not found' });
}
return {
success: true,
message: 'Document deleted successfully',
};
}

View File

@ -0,0 +1,34 @@
import type { User } from '@buster/database';
import { getDoc, getUserOrganizationId } from '@buster/database';
import type { GetDocResponse } from '@buster/server-shared/docs';
import { HTTPException } from 'hono/http-exception';
export async function getDocHandler(docId: string, user: User): Promise<GetDocResponse> {
const userToOrg = await getUserOrganizationId(user.id);
if (!userToOrg) {
throw new HTTPException(403, {
message: 'User is not associated with an organization',
});
}
const doc = await getDoc({
id: docId,
organizationId: userToOrg.organizationId,
});
if (!doc) {
throw new HTTPException(404, { message: 'Document not found' });
}
return {
id: doc.id,
name: doc.name,
content: doc.content,
type: doc.type as 'analyst' | 'normal',
organizationId: doc.organizationId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
deletedAt: doc.deletedAt,
};
}

View File

@ -0,0 +1,49 @@
import type { User } from '@buster/database';
import { getUserOrganizationId, updateDoc } from '@buster/database';
import type { UpdateDocRequest, UpdateDocResponse } from '@buster/server-shared/docs';
import { HTTPException } from 'hono/http-exception';
export async function updateDocHandler(
docId: string,
request: UpdateDocRequest,
user: User
): Promise<UpdateDocResponse> {
const { name, content, type } = request;
const userToOrg = await getUserOrganizationId(user.id);
if (!userToOrg) {
throw new HTTPException(403, {
message: 'User is not associated with an organization',
});
}
if (userToOrg.role !== 'workspace_admin' && userToOrg.role !== 'data_admin') {
throw new HTTPException(403, {
message: 'User is not an admin',
});
}
const doc = await updateDoc({
id: docId,
organizationId: userToOrg.organizationId,
name,
content,
type: type as 'analyst' | 'normal' | undefined,
});
if (!doc) {
throw new HTTPException(404, { message: 'Document not found' });
}
return {
id: doc.id,
name: doc.name,
content: doc.content,
type: doc.type as 'analyst' | 'normal',
organizationId: doc.organizationId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
deletedAt: doc.deletedAt,
};
}

View File

@ -0,0 +1,88 @@
import {
CreateDocRequestSchema,
GetDocByIdParamsSchema,
GetDocsListRequestSchema,
UpdateDocRequestSchema,
} from '@buster/server-shared/docs';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { requireAuth } from '../../../middleware/auth';
import { standardErrorHandler } from '../../../utils/response';
import { listDocsHandler } from './GET';
import { createDocHandler } from './POST';
import { deleteDocHandler } from './id/DELETE';
import { getDocHandler } from './id/GET';
import { updateDocHandler } from './id/PUT';
const app = new Hono()
.use('*', requireAuth)
// GET /docs - List all docs
.get('/', zValidator('query', GetDocsListRequestSchema), async (c) => {
const request = c.req.valid('query');
const user = c.get('busterUser');
const response = await listDocsHandler(request, user);
return c.json(response);
})
// POST /docs - Create new doc (upsert)
.post('/', zValidator('json', CreateDocRequestSchema), async (c) => {
const request = c.req.valid('json');
const user = c.get('busterUser');
const response = await createDocHandler(request, user);
return c.json(response);
})
// GET /docs/:id - Get single doc
.get('/:id', zValidator('param', GetDocByIdParamsSchema), async (c) => {
const params = c.req.valid('param');
const user = c.get('busterUser');
const response = await getDocHandler(params.id, user);
return c.json(response);
})
// PUT /docs/:id - Update doc
.put(
'/:id',
zValidator('param', GetDocByIdParamsSchema),
zValidator('json', UpdateDocRequestSchema),
async (c) => {
const params = c.req.valid('param');
const request = c.req.valid('json');
const user = c.get('busterUser');
const response = await updateDocHandler(params.id, request, user);
return c.json(response);
}
)
// PATCH /docs/:id - Update doc (same as PUT)
.patch(
'/:id',
zValidator('param', GetDocByIdParamsSchema),
zValidator('json', UpdateDocRequestSchema),
async (c) => {
const params = c.req.valid('param');
const request = c.req.valid('json');
const user = c.get('busterUser');
const response = await updateDocHandler(params.id, request, user);
return c.json(response);
}
)
// DELETE /docs/:id - Soft delete doc
.delete('/:id', zValidator('param', GetDocByIdParamsSchema), async (c) => {
const params = c.req.valid('param');
const user = c.get('busterUser');
const response = await deleteDocHandler(params.id, user);
return c.json(response);
})
.onError(standardErrorHandler);
export default app;

View File

@ -5,6 +5,7 @@ import authRoutes from './auth';
import chatsRoutes from './chats';
import datasetsRoutes from './datasets';
import dictionariesRoutes from './dictionaries';
import docsRoutes from './docs';
import electricShapeRoutes from './electric-shape';
import githubRoutes from './github';
import metricFilesRoutes from './metric_files';
@ -22,6 +23,7 @@ const app = new Hono()
.route('/auth', authRoutes)
.route('/users', userRoutes)
.route('/datasets', datasetsRoutes)
.route('/docs', docsRoutes)
.route('/electric-shape', electricShapeRoutes)
.route('/healthcheck', healthcheckRoutes)
.route('/chats', chatsRoutes)

View File

@ -1,13 +0,0 @@
CREATE TYPE "public"."docs_type_enum" AS ENUM('analyst', 'normal');--> statement-breakpoint
CREATE TABLE "docs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"content" text NOT NULL,
"type" "docs_type_enum" DEFAULT 'normal' NOT NULL,
"organization_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "docs" ADD CONSTRAINT "docs_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -0,0 +1 @@
ALTER TABLE "docs" ADD CONSTRAINT "docs_name_organization_id_key" UNIQUE("name","organization_id");

File diff suppressed because it is too large Load Diff

View File

@ -652,6 +652,13 @@
"when": 1757364170176,
"tag": "0092_high_wendell_vaughn",
"breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1757366674063,
"tag": "0093_friendly_puff_adder",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,24 @@
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '../../connection';
import { docs } from '../../schema';
export interface DeleteDocParams {
id: string;
organizationId: string;
}
export async function deleteDoc(params: DeleteDocParams) {
const { id, organizationId } = params;
// Soft delete by setting deletedAt
const [deletedDoc] = await db
.update(docs)
.set({
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.where(and(eq(docs.id, id), eq(docs.organizationId, organizationId), isNull(docs.deletedAt)))
.returning();
return deletedDoc || null;
}

View File

@ -0,0 +1,20 @@
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '../../connection';
import { docs } from '../../schema';
export interface GetDocParams {
id: string;
organizationId: string;
}
export async function getDoc(params: GetDocParams) {
const { id, organizationId } = params;
const [doc] = await db
.select()
.from(docs)
.where(and(eq(docs.id, id), eq(docs.organizationId, organizationId), isNull(docs.deletedAt)))
.limit(1);
return doc || null;
}

View File

@ -0,0 +1,5 @@
export * from './upsert-doc';
export * from './get-doc';
export * from './list-docs';
export * from './update-doc';
export * from './delete-doc';

View File

@ -0,0 +1,53 @@
import { and, eq, isNull, like, sql } from 'drizzle-orm';
import { db } from '../../connection';
import { docs } from '../../schema';
export interface ListDocsParams {
organizationId: string;
type?: 'analyst' | 'normal';
search?: string;
page?: number;
pageSize?: number;
}
export async function listDocs(params: ListDocsParams) {
const { organizationId, type, search, page = 1, pageSize = 20 } = params;
const offset = (page - 1) * pageSize;
// Build where conditions
const conditions = [eq(docs.organizationId, organizationId), isNull(docs.deletedAt)];
if (type) {
conditions.push(eq(docs.type, type));
}
if (search) {
conditions.push(like(docs.name, `%${search}%`));
}
// Get total count
const [countResult] = await db
.select({ count: sql<number>`count(*)::int` })
.from(docs)
.where(and(...conditions));
const count = countResult?.count ?? 0;
// Get paginated results
const results = await db
.select()
.from(docs)
.where(and(...conditions))
.orderBy(docs.updatedAt)
.limit(pageSize)
.offset(offset);
return {
data: results,
total: count,
page,
pageSize,
totalPages: Math.ceil(count / pageSize),
};
}

View File

@ -0,0 +1,32 @@
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '../../connection';
import { docs } from '../../schema';
export interface UpdateDocParams {
id: string;
organizationId: string;
name?: string;
content?: string;
type?: 'analyst' | 'normal';
}
export async function updateDoc(params: UpdateDocParams) {
const { id, organizationId, ...updates } = params;
// Filter out undefined values
const updateData: Record<string, string> = {};
if (updates.name !== undefined) updateData.name = updates.name;
if (updates.content !== undefined) updateData.content = updates.content;
if (updates.type !== undefined) updateData.type = updates.type;
// Always update updatedAt
updateData.updatedAt = new Date().toISOString();
const [updatedDoc] = await db
.update(docs)
.set(updateData)
.where(and(eq(docs.id, id), eq(docs.organizationId, organizationId), isNull(docs.deletedAt)))
.returning();
return updatedDoc || null;
}

View File

@ -0,0 +1,45 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { docs } from '../../schema';
export const UpsertDocSchema = z.object({
name: z.string().min(1).max(255),
content: z.string(),
type: z.enum(['analyst', 'normal']),
organizationId: z.string().uuid(),
});
export type UpsertDocParams = z.infer<typeof UpsertDocSchema>;
export async function upsertDoc(params: UpsertDocParams) {
const { name, content, type = 'normal', organizationId } = params;
try {
// Update existing doc and unmark deleted_at if it was soft deleted
const [updatedDoc] = await db
.insert(docs)
.values({
name,
content,
type,
organizationId,
updatedAt: new Date().toISOString(),
})
.onConflictDoUpdate({
target: [docs.name, docs.organizationId],
set: {
content,
type,
updatedAt: new Date().toISOString(),
deletedAt: null, // Unmark soft delete
},
})
.returning();
return updatedDoc;
} catch (error) {
console.error('Error upserting doc:', error);
throw new Error('Failed to upsert doc');
}
}

View File

@ -13,6 +13,7 @@ export * from './dashboards';
export * from './metrics';
export * from './collections';
export * from './reports';
export * from './docs';
export * from './s3-integrations';
export * from './vault';
export * from './cascading-permissions';

View File

@ -35,4 +35,3 @@ export type UserOrganizationRole =
| 'querier'
| 'restricted_querier'
| 'viewer';

View File

@ -2315,5 +2315,6 @@ export const docs = pgTable(
foreignColumns: [organizations.id],
name: 'docs_organization_id_fkey',
}).onDelete('cascade'),
unique('docs_name_organization_id_key').on(table.name, table.organizationId),
]
)
);

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
const DocTypeSchema = z.enum(['analyst', 'normal']);
export type DocType = z.infer<typeof DocTypeSchema>;

View File

@ -32,6 +32,10 @@
"types": "./dist/dashboards/index.d.ts",
"default": "./dist/dashboards/index.js"
},
"./docs": {
"types": "./dist/docs/index.d.ts",
"default": "./dist/docs/index.js"
},
"./github": {
"types": "./dist/github/index.d.ts",
"default": "./dist/github/index.js"

View File

@ -0,0 +1,3 @@
export * from './types';
export * from './requests';
export * from './responses';

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
import { PaginatedRequestSchema } from '../type-utilities/pagination';
import { DocsTypeEnum } from './types';
export const CreateDocRequestSchema = z.object({
name: z.string().min(1).max(255),
content: z.string(),
type: DocsTypeEnum,
});
export const UpdateDocRequestSchema = z.object({
name: z.string().min(1).max(255).optional(),
content: z.string().optional(),
type: DocsTypeEnum.optional(),
});
export const GetDocsListRequestSchema = PaginatedRequestSchema.extend({
type: DocsTypeEnum.optional(),
search: z.string().optional(),
});
export const GetDocByIdParamsSchema = z.object({
id: z.string().uuid(),
});
export type CreateDocRequest = z.infer<typeof CreateDocRequestSchema>;
export type UpdateDocRequest = z.infer<typeof UpdateDocRequestSchema>;
export type GetDocsListRequest = z.infer<typeof GetDocsListRequestSchema>;
export type GetDocByIdParams = z.infer<typeof GetDocByIdParamsSchema>;

View File

@ -0,0 +1,27 @@
import { z } from 'zod';
import { DocSchema } from './types';
export const GetDocResponseSchema = DocSchema;
export const CreateDocResponseSchema = DocSchema;
export const UpdateDocResponseSchema = DocSchema;
export const DeleteDocResponseSchema = z.object({
success: z.boolean(),
message: z.string(),
});
export const GetDocsListResponseSchema = z.object({
data: z.array(DocSchema),
total: z.number(),
page: z.number(),
page_size: z.number(),
total_pages: z.number(),
});
export type GetDocResponse = z.infer<typeof GetDocResponseSchema>;
export type CreateDocResponse = z.infer<typeof CreateDocResponseSchema>;
export type UpdateDocResponse = z.infer<typeof UpdateDocResponseSchema>;
export type DeleteDocResponse = z.infer<typeof DeleteDocResponseSchema>;
export type GetDocsListResponse = z.infer<typeof GetDocsListResponseSchema>;

View File

@ -0,0 +1,18 @@
import { docsTypeEnum } from '@buster/database';
import { z } from 'zod';
export const DocsTypeEnum = z.enum(docsTypeEnum.enumValues);
export type DocsType = z.infer<typeof DocsTypeEnum>;
export const DocSchema = z.object({
id: z.string().uuid(),
name: z.string(),
content: z.string(),
type: DocsTypeEnum,
organizationId: z.string().uuid(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string().nullable(),
});
export type Doc = z.infer<typeof DocSchema>;

View File

@ -10,6 +10,7 @@ export * from './chats';
export * from './dashboards';
export * from './datasets';
export * from './dictionary';
export * from './docs';
export * from './github';
export * from './message';
export * from './metrics';