diff --git a/apps/server/src/api/v2/reports/[id]/GET.ts b/apps/server/src/api/v2/reports/[id]/GET.ts index de06cc749..03affef58 100644 --- a/apps/server/src/api/v2/reports/[id]/GET.ts +++ b/apps/server/src/api/v2/reports/[id]/GET.ts @@ -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 { - 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) => { diff --git a/apps/server/src/api/v2/reports/[id]/PUT.ts b/apps/server/src/api/v2/reports/[id]/PUT.ts index dd7f15bc5..57355e6bf 100644 --- a/apps/server/src/api/v2/reports/[id]/PUT.ts +++ b/apps/server/src/api/v2/reports/[id]/PUT.ts @@ -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 { - 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), - 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; } diff --git a/apps/server/src/api/v2/reports/[id]/index.ts b/apps/server/src/api/v2/reports/[id]/index.ts index e92c86d24..d7671fb14 100644 --- a/apps/server/src/api/v2/reports/[id]/index.ts +++ b/apps/server/src/api/v2/reports/[id]/index.ts @@ -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; diff --git a/apps/server/src/api/v2/reports/[id]/sharing/PUT.ts b/apps/server/src/api/v2/reports/[id]/sharing/PUT.ts new file mode 100644 index 000000000..c928d3faf --- /dev/null +++ b/apps/server/src/api/v2/reports/[id]/sharing/PUT.ts @@ -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; diff --git a/apps/server/src/api/v2/reports/[id]/sharing/index.ts b/apps/server/src/api/v2/reports/[id]/sharing/index.ts new file mode 100644 index 000000000..908966460 --- /dev/null +++ b/apps/server/src/api/v2/reports/[id]/sharing/index.ts @@ -0,0 +1,6 @@ +import { Hono } from 'hono'; +import PUT from './PUT'; + +const app = new Hono().route('/', PUT); + +export default app; diff --git a/apps/web/src/api/asset_interfaces/shared_interfaces/index.ts b/apps/web/src/api/asset_interfaces/shared_interfaces/index.ts deleted file mode 100644 index 805f9e87c..000000000 --- a/apps/web/src/api/asset_interfaces/shared_interfaces/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './shareInterfaces'; diff --git a/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts b/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts deleted file mode 100644 index bd2d9cc28..000000000 --- a/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts +++ /dev/null @@ -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; -}; diff --git a/packages/database/src/queries/reports/get-report.ts b/packages/database/src/queries/reports/get-report.ts index 9340b15a6..20bb0c8a5 100644 --- a/packages/database/src/queries/reports/get-report.ts +++ b/packages/database/src/queries/reports/get-report.ts @@ -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)) diff --git a/packages/database/src/queries/reports/get-reports-list.ts b/packages/database/src/queries/reports/get-reports-list.ts index fc96f2192..bd3a3260d 100644 --- a/packages/database/src/queries/reports/get-reports-list.ts +++ b/packages/database/src/queries/reports/get-reports-list.ts @@ -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 diff --git a/packages/database/src/queries/reports/index.ts b/packages/database/src/queries/reports/index.ts index 8afdddbee..36a77de0e 100644 --- a/packages/database/src/queries/reports/index.ts +++ b/packages/database/src/queries/reports/index.ts @@ -1,3 +1,4 @@ export * from './get-report-title'; export * from './get-reports-list'; export * from './get-report'; +export * from './update-report'; diff --git a/packages/database/src/queries/reports/update-report.ts b/packages/database/src/queries/reports/update-report.ts new file mode 100644 index 000000000..56cd63873 --- /dev/null +++ b/packages/database/src/queries/reports/update-report.ts @@ -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 + >, + public_expiry_date: z.string().optional(), + public_password: z.string().optional(), + workspace_sharing: WorkspaceSharingSchema.optional(), +}); + +type UpdateReportInput = z.infer; + +/** + * 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 => { + // 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'); + } +}; diff --git a/packages/server-shared/src/collections/index.ts b/packages/server-shared/src/collections/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-shared/src/collections/shared-asset-collections.ts b/packages/server-shared/src/collections/shared-asset-collections.ts new file mode 100644 index 000000000..1f1c14724 --- /dev/null +++ b/packages/server-shared/src/collections/shared-asset-collections.ts @@ -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; + +export const AssetCollectionsSchema = z.array(AssetCollectionSchema); + +export type AssetCollections = z.infer; diff --git a/packages/server-shared/src/lib/report/markdown-to-platejs.ts b/packages/server-shared/src/lib/report/markdown-to-platejs.ts index 6ef12692f..94841946b 100644 --- a/packages/server-shared/src/lib/report/markdown-to-platejs.ts +++ b/packages/server-shared/src/lib/report/markdown-to-platejs.ts @@ -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 = [ diff --git a/packages/server-shared/src/reports/example.ts b/packages/server-shared/src/reports/example.ts index 28f350ed8..6ce726253 100644 --- a/packages/server-shared/src/reports/example.ts +++ b/packages/server-shared/src/reports/example.ts @@ -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 = [ { diff --git a/packages/server-shared/src/reports/reports.types.ts b/packages/server-shared/src/reports/reports.types.ts index 2f74c009b..06957ae9e 100644 --- a/packages/server-shared/src/reports/reports.types.ts +++ b/packages/server-shared/src/reports/reports.types.ts @@ -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, + version_number: z.number(), + version_history: VersionsSchema, + collections: AssetCollectionsSchema, + individual_permissions: IndividualPermissionsSchema, + content: z.any() as z.ZodType, //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; diff --git a/packages/server-shared/src/reports/requests.ts b/packages/server-shared/src/reports/requests.ts index 838ef2328..6e83ee564 100644 --- a/packages/server-shared/src/reports/requests.ts +++ b/packages/server-shared/src/reports/requests.ts @@ -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 - >, + content: z.any().optional() as z.ZodOptional>, //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; export type GetReportsListRequest = z.infer; + +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; diff --git a/packages/server-shared/src/reports/responses.ts b/packages/server-shared/src/reports/responses.ts index 22b3fda4a..9444fcda2 100644 --- a/packages/server-shared/src/reports/responses.ts +++ b/packages/server-shared/src/reports/responses.ts @@ -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; export type UpdateReportResponse = z.infer; export type GetReportIndividualResponse = z.infer; +export type ShareUpdateResponse = z.infer; diff --git a/packages/server-shared/src/shared-permissions/index.ts b/packages/server-shared/src/shared-permissions/index.ts new file mode 100644 index 000000000..590f03687 --- /dev/null +++ b/packages/server-shared/src/shared-permissions/index.ts @@ -0,0 +1,43 @@ +import type { assetPermissionRoleEnum, workspaceSharingEnum } from '@buster/database'; +import { z } from 'zod'; + +type AssetPermissionRoleBase = (typeof assetPermissionRoleEnum.enumValues)[number]; +const AssetPermissionRoleEnums: Record = + 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; + +export const IndividualPermissionsSchema = z.array(IndividualPermissionSchema); + +export type IndividualPermissions = z.infer; + +type WorkspaceSharingBase = (typeof workspaceSharingEnum.enumValues)[number]; +const WorkspaceSharingEnums: Record = 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[]] +); diff --git a/packages/server-shared/src/version-shared/index.ts b/packages/server-shared/src/version-shared/index.ts new file mode 100644 index 000000000..a06b36448 --- /dev/null +++ b/packages/server-shared/src/version-shared/index.ts @@ -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; + +export const VersionsSchema = z.array(VersionSchema); + +export type Versions = z.infer;