collection and assets queries

This commit is contained in:
Nate Kelley 2025-08-04 15:37:48 -06:00
parent 1a955d786b
commit 87ea87e963
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
20 changed files with 356 additions and 112 deletions

View File

@ -1,43 +1,17 @@
import type { User } from '@buster/database';
import type {
GetReportIndividualResponse,
ReportIndividualResponse,
} from '@buster/server-shared/reports';
import { zValidator } from '@hono/zod-validator';
import { getReport } from '@buster/database';
import type { GetReportIndividualResponse } from '@buster/server-shared/reports';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
async function getReportHandler(
export async function getReportHandler(
reportId: string,
user: User
user: { id: string }
): Promise<GetReportIndividualResponse> {
return {
id: reportId,
name: 'Sales Analysis Q4',
file_name: 'sales_analysis_q4.md',
description: 'Quarterly sales performance analysis',
created_by: user.id,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-20T14:30:00Z',
deleted_at: null,
publicly_accessible: false,
content: [
{
type: 'h1',
children: [{ text: 'Sales Analysis Q4' }],
},
{
type: 'p',
children: [{ text: 'This report analyzes our Q4 sales performance.' }],
},
{
type: 'metric',
metricId: '123',
children: [{ text: '' }],
caption: [{ text: 'This is a metric' }],
},
],
};
const report = await getReport({ reportId, userId: user.id });
const response: GetReportIndividualResponse = report;
return response;
}
const app = new Hono().get('/', async (c) => {

View File

@ -1,46 +1,47 @@
import type { User } from '@buster/database';
import { getUserOrganizationId, updateReport } from '@buster/database';
import type { UpdateReportRequest, UpdateReportResponse } from '@buster/server-shared/reports';
import { UpdateReportRequestSchema } from '@buster/server-shared/reports';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { getReportHandler } from './GET';
async function updateReportHandler(
reportId: string,
request: UpdateReportRequest,
user: User
): Promise<UpdateReportResponse> {
const existingReport: UpdateReportResponse = {
id: reportId,
name: 'Sales Analysis Q4',
file_name: 'sales_analysis_q4.md',
description: 'Quarterly sales performance analysis',
created_by: user.id,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-20T14:30:00Z',
deleted_at: null,
publicly_accessible: false,
content: [
{
type: 'h1' as const,
children: [{ text: 'Sales Analysis Q4' }],
},
{
type: 'p' as const,
children: [{ text: 'This report analyzes our Q4 sales performance.' }],
},
],
};
if (!reportId || reportId === 'invalid') {
throw new HTTPException(404, { message: 'Report not found' });
}
const updatedReport: UpdateReportResponse = {
...existingReport,
...(request as Partial<UpdateReportResponse>),
updated_at: new Date().toISOString(),
};
// Get user's organization ID
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User is not associated with an organization' });
}
const _hasPermissionToEditAsset = true; //DALLIN: Check if user has permission to edit asset
if (!_hasPermissionToEditAsset) {
throw new HTTPException(403, { message: 'User does not have permission to edit asset' });
}
const { name, content } = request;
// Update the report in the database
await updateReport({
reportId,
organizationId: userOrg.organizationId,
userId: user.id,
name,
content,
});
// Get and return the updated report
const updatedReport: UpdateReportResponse = await getReportHandler(reportId, user);
return updatedReport;
}

View File

@ -2,7 +2,8 @@ import { Hono } from 'hono';
import { requireAuth } from '../../../../middleware/auth';
import GET from './GET';
import PUT from './PUT';
import SHARING from './sharing';
const app = new Hono().route('/', GET).route('/', PUT);
const app = new Hono().route('/', GET).route('/', PUT).route('/sharing', SHARING);
export default app;

View File

@ -0,0 +1,70 @@
import { getUserOrganizationId, updateReport } from '@buster/database';
import {
type ShareUpdateRequest,
ShareUpdateRequestSchema,
type ShareUpdateResponse,
type UpdateReportResponse,
} from '@buster/server-shared/reports';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { getReportHandler } from '../GET';
async function updateReportShareHandler(
reportId: string,
request: ShareUpdateRequest,
user: { id: string; organizationId: string }
) {
const _hasPermissionToEditAssetPermissions = true; //DALLIN: Check if user has permission to edit asset permissions
if (!_hasPermissionToEditAssetPermissions) {
throw new HTTPException(403, {
message: 'User does not have permission to edit asset permissions',
});
}
const { publicly_accessible, public_expiry_date, public_password, workspace_sharing } = request;
if (publicly_accessible || public_expiry_date || public_password || workspace_sharing) {
//DALLIN: Check if user has permission to edit settings
}
await updateReport({
reportId,
organizationId: user.organizationId,
userId: user.id,
publicly_accessible,
public_expiry_date,
public_password,
workspace_sharing,
});
const updatedReport: UpdateReportResponse = await getReportHandler(reportId, user);
return updatedReport;
}
const app = new Hono().put('/', zValidator('json', ShareUpdateRequestSchema), async (c) => {
const reportId = c.req.param('id');
const request = c.req.valid('json');
const user = c.get('busterUser');
if (!reportId) {
throw new HTTPException(404, { message: 'Report not found' });
}
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User is not associated with an organization' });
}
const updatedReport: ShareUpdateResponse = await updateReportShareHandler(reportId, request, {
id: user.id,
organizationId: userOrg.organizationId,
});
return c.json(updatedReport);
});
export default app;

View File

@ -0,0 +1,6 @@
import { Hono } from 'hono';
import PUT from './PUT';
const app = new Hono().route('/', PUT);
export default app;

View File

@ -1 +0,0 @@
export * from './shareInterfaces';

View File

@ -1,14 +0,0 @@
import type { ShareRole, WorkspaceShareRole } from '@buster/server-shared/share';
export type ShareDeleteRequest = string[];
export type ShareUpdateRequest = {
users?: {
email: string;
role: ShareRole;
}[];
workspace_sharing?: WorkspaceShareRole | null;
publicly_accessible?: boolean;
public_password?: string | null;
public_expiry_date?: string | null;
};

View File

@ -80,10 +80,11 @@ export async function getReport(input: GetReportInput) {
// Individual permissions query - get users with direct permissions to this report
const individualPermissionsQuery = db
.select({
id: users.id,
role: assetPermissions.role,
email: users.email,
name: users.name,
avatarUrl: users.avatarUrl,
avatar_url: users.avatarUrl,
})
.from(assetPermissions)
.innerJoin(users, eq(users.id, assetPermissions.identityId))

View File

@ -35,7 +35,11 @@ export type ReportListItem = {
/**
* Get paginated list of reports for the user's organization
* with optional filtering by name, dates, and public accessibility
* with optional filtering by name, dates, and public accessibility.
*
* Security note: When deleted date filters (deleted_after/deleted_before) are provided,
* this function returns only deleted reports within that date range.
* Otherwise, it returns only non-deleted reports.
*/
export async function getReportsList(
input: GetReportsListInput
@ -64,20 +68,27 @@ export async function getReportsList(
const { organizationId } = userOrg;
// Build dynamic where conditions
// Only include the isNull check if no deleted date filters are provided
// Check if deleted date filters are provided
const hasDeletedFilters = deleted_after !== undefined || deleted_before !== undefined;
const whereConditions = and(
eq(reportFiles.organizationId, organizationId),
// Only exclude deleted reports if no deleted date filters are provided
!hasDeletedFilters ? isNull(reportFiles.deletedAt) : undefined,
// Security fix: Always apply proper deletion filtering
// If deleted date filters are provided, show only deleted reports in that range
// Otherwise, show only non-deleted reports
hasDeletedFilters
? and(
// Show only deleted reports within the specified date range
deleted_after ? gte(reportFiles.deletedAt, deleted_after) : undefined,
deleted_before ? lte(reportFiles.deletedAt, deleted_before) : undefined
)
: // Show only non-deleted reports
isNull(reportFiles.deletedAt),
name ? like(reportFiles.name, `%${name}%`) : undefined,
created_after ? gte(reportFiles.createdAt, created_after) : undefined,
created_before ? lte(reportFiles.createdAt, created_before) : undefined,
updated_after ? gte(reportFiles.updatedAt, updated_after) : undefined,
updated_before ? lte(reportFiles.updatedAt, updated_before) : undefined,
deleted_after ? gte(reportFiles.deletedAt, deleted_after) : undefined,
deleted_before ? lte(reportFiles.deletedAt, deleted_before) : undefined,
publicly_accessible !== undefined
? eq(reportFiles.publiclyAccessible, publicly_accessible)
: undefined

View File

@ -1,3 +1,4 @@
export * from './get-report-title';
export * from './get-reports-list';
export * from './get-report';
export * from './update-report';

View File

@ -0,0 +1,124 @@
import { and, eq, isNull } from 'drizzle-orm';
import {} from 'drizzle-zod';
import { z } from 'zod';
import { db } from '../../connection';
import { reportFiles } from '../../schema';
import { workspaceSharingEnum } from '../../schema';
import { ReportElementSchema, type ReportElements } from '../../schema-types';
const WorkspaceSharingSchema = z.enum(workspaceSharingEnum.enumValues);
// Input validation schema for updating a report
const UpdateReportInputSchema = z.object({
reportId: z.string().uuid('Report ID must be a valid UUID'),
organizationId: z.string().uuid('Organization ID must be a valid UUID'),
userId: z.string().uuid('User ID must be a valid UUID'),
name: z.string().optional(),
publicly_accessible: z.boolean().optional(),
content: z.lazy(() => z.array(ReportElementSchema)).optional() as z.ZodOptional<
z.ZodType<ReportElements>
>,
public_expiry_date: z.string().optional(),
public_password: z.string().optional(),
workspace_sharing: WorkspaceSharingSchema.optional(),
});
type UpdateReportInput = z.infer<typeof UpdateReportInputSchema>;
/**
* Updates a report with the provided fields
* Only updates fields that are provided in the input
* Always updates the updatedAt timestamp
*/
export const updateReport = async (params: UpdateReportInput): Promise<void> => {
// Validate and destructure input
const {
reportId,
organizationId,
userId,
name,
publicly_accessible,
content,
public_expiry_date,
public_password,
workspace_sharing,
} = UpdateReportInputSchema.parse(params);
try {
// Build update data - only include fields that are provided
const updateData: {
updatedAt: string;
name?: string;
publiclyAccessible?: boolean;
publiclyEnabledBy?: string | null;
content?: ReportElements;
publicExpiryDate?: string;
publicPassword?: string;
workspaceSharing?: 'none' | 'can_view' | 'can_edit' | 'full_access';
workspaceSharingEnabledBy?: string | null;
workspaceSharingEnabledAt?: string | null;
} = {
updatedAt: new Date().toISOString(),
};
// Only add fields that are provided
if (name !== undefined) {
updateData.name = name;
}
if (publicly_accessible !== undefined) {
updateData.publiclyAccessible = publicly_accessible;
// Set publiclyEnabledBy to userId when enabling, null when disabling
updateData.publiclyEnabledBy = publicly_accessible ? userId : null;
}
if (content !== undefined) {
updateData.content = content;
}
if (public_expiry_date !== undefined) {
updateData.publicExpiryDate = public_expiry_date;
}
if (public_password !== undefined) {
updateData.publicPassword = public_password;
}
if (workspace_sharing !== undefined) {
updateData.workspaceSharing = workspace_sharing;
if (workspace_sharing !== 'none') {
updateData.workspaceSharingEnabledBy = userId;
updateData.workspaceSharingEnabledAt = new Date().toISOString();
} else {
updateData.workspaceSharingEnabledBy = null;
updateData.workspaceSharingEnabledAt = null;
}
}
// Update report in database
await db
.update(reportFiles)
.set(updateData)
.where(
and(
eq(reportFiles.id, reportId),
eq(reportFiles.organizationId, organizationId),
isNull(reportFiles.deletedAt)
)
);
} catch (error) {
console.error('Error updating report:', {
reportId,
organizationId,
error: error instanceof Error ? error.message : error,
});
// Re-throw with more context
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to update report');
}
};

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
export const AssetCollectionSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
});
export type AssetCollection = z.infer<typeof AssetCollectionSchema>;
export const AssetCollectionsSchema = z.array(AssetCollectionSchema);
export type AssetCollections = z.infer<typeof AssetCollectionsSchema>;

View File

@ -1,3 +1,4 @@
import { ReportElementsSchema } from '@buster/database';
import { AutoformatPlugin } from '@platejs/autoformat';
import {
BaseBasicBlocksPlugin,
@ -22,7 +23,6 @@ import {
BaseUnderlinePlugin,
} from '@platejs/basic-nodes';
import { createSlateEditor } from 'platejs';
import { ReportElementsSchema } from '../../../../database/src/schema-types/report-elements';
import { MarkdownPlugin } from './MarkdownPlugin';
const serverNode = [

View File

@ -1,4 +1,4 @@
import type { ReportElement } from '../../../database/src/schema-types/report-elements';
import type { ReportElement } from '@buster/database';
export const SAMPLE_REPORT_ELEMENTS = [
{

View File

@ -1,6 +1,8 @@
import type { ReportElement, ReportElements } from '@buster/database';
import { ReportElementSchema } from '@buster/database';
import type { ReportElements } from '@buster/database';
import { z } from 'zod';
import { AssetCollectionsSchema } from '../collections/shared-asset-collections';
import { IndividualPermissionsSchema } from '../shared-permissions';
import { VersionsSchema } from '../version-shared';
export const ReportListItemSchema = z.object({
id: z.string(),
@ -12,28 +14,20 @@ export const ReportListItemSchema = z.object({
publicly_accessible: z.boolean(),
});
export const ReportIndividualResponseSchema: z.ZodType<{
id: string;
name: string;
file_name: string;
description: string;
created_by: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
publicly_accessible: boolean;
content: ReportElement[];
}> = z.object({
export const ReportIndividualResponseSchema = z.object({
id: z.string(),
name: z.string(),
file_name: z.string(),
description: z.string(),
created_by: z.string(),
created_at: z.string(),
updated_at: z.string(),
deleted_at: z.string().nullable(),
created_by_id: z.string(),
created_by_name: z.string().nullable(),
created_by_avatar: z.string().nullable(),
publicly_accessible: z.boolean(),
content: z.array(ReportElementSchema) as z.ZodType<ReportElements>,
version_number: z.number(),
version_history: VersionsSchema,
collections: AssetCollectionsSchema,
individual_permissions: IndividualPermissionsSchema,
content: z.any() as z.ZodType<ReportElements>, //we use any here because we don't know the type of the content, will be validated in the database
});
export type ReportListItem = z.infer<typeof ReportListItemSchema>;

View File

@ -1,5 +1,6 @@
import { ReportElementSchema, type ReportElements } from '@buster/database';
import type { ReportElements } from '@buster/database';
import { z } from 'zod';
import { WorkspaceSharingSchema } from '../shared-permissions';
import { PaginatedRequestSchema } from '../type-utilities/pagination';
export const GetReportsListRequestSchema = PaginatedRequestSchema;
@ -8,13 +9,18 @@ export const GetReportsListRequestSchema = PaginatedRequestSchema;
export const UpdateReportRequestSchema = z
.object({
name: z.string().optional(),
description: z.string().optional(),
publicly_accessible: z.boolean().optional(),
content: z.lazy(() => z.array(ReportElementSchema)).optional() as z.ZodOptional<
z.ZodType<ReportElements>
>,
content: z.any().optional() as z.ZodOptional<z.ZodType<ReportElements>>, //we use any here because we don't know the type of the content, will be validated in the database
})
.partial();
export type UpdateReportRequest = z.infer<typeof UpdateReportRequestSchema>;
export type GetReportsListRequest = z.infer<typeof GetReportsListRequestSchema>;
export const ShareUpdateRequestSchema = z.object({
publicly_accessible: z.boolean().optional(),
public_expiry_date: z.string().optional(),
public_password: z.string().optional(),
workspace_sharing: WorkspaceSharingSchema.optional(),
});
export type ShareUpdateRequest = z.infer<typeof ShareUpdateRequestSchema>;

View File

@ -4,7 +4,9 @@ import { ReportIndividualResponseSchema, ReportListItemSchema } from './reports.
export const GetReportsListResponseSchema = PaginatedResponseSchema(ReportListItemSchema);
export const UpdateReportResponseSchema = ReportIndividualResponseSchema;
export const ShareUpdateResponseSchema = ReportIndividualResponseSchema;
export type GetReportsListResponse = z.infer<typeof GetReportsListResponseSchema>;
export type UpdateReportResponse = z.infer<typeof UpdateReportResponseSchema>;
export type GetReportIndividualResponse = z.infer<typeof ReportIndividualResponseSchema>;
export type ShareUpdateResponse = z.infer<typeof ShareUpdateResponseSchema>;

View File

@ -0,0 +1,43 @@
import type { assetPermissionRoleEnum, workspaceSharingEnum } from '@buster/database';
import { z } from 'zod';
type AssetPermissionRoleBase = (typeof assetPermissionRoleEnum.enumValues)[number];
const AssetPermissionRoleEnums: Record<AssetPermissionRoleBase, AssetPermissionRoleBase> =
Object.freeze({
owner: 'owner',
viewer: 'viewer',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
});
export const AssetPermissionRoleSchema = z.enum(
Object.values(AssetPermissionRoleEnums) as [AssetPermissionRoleBase, ...AssetPermissionRoleBase[]]
);
export const IndividualPermissionSchema = z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string(),
avatar_url: z.string().nullable(),
role: AssetPermissionRoleSchema,
});
export type IndividualPermission = z.infer<typeof IndividualPermissionSchema>;
export const IndividualPermissionsSchema = z.array(IndividualPermissionSchema);
export type IndividualPermissions = z.infer<typeof IndividualPermissionsSchema>;
type WorkspaceSharingBase = (typeof workspaceSharingEnum.enumValues)[number];
const WorkspaceSharingEnums: Record<WorkspaceSharingBase, WorkspaceSharingBase> = Object.freeze({
none: 'none',
can_view: 'can_view',
can_edit: 'can_edit',
full_access: 'full_access',
});
export const WorkspaceSharingSchema = z.enum(
Object.values(WorkspaceSharingEnums) as [WorkspaceSharingBase, ...WorkspaceSharingBase[]]
);

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const VersionSchema = z.object({
version_number: z.number(),
updated_at: z.string(),
});
export type Version = z.infer<typeof VersionSchema>;
export const VersionsSchema = z.array(VersionSchema);
export type Versions = z.infer<typeof VersionsSchema>;