Merge pull request #1250 from buster-so/wells-bus-2026-create-backend-for-library

Adding the Backend for library
This commit is contained in:
wellsbunk5 2025-10-02 13:16:43 -06:00 committed by GitHub
commit e3a14d9d99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 6878 additions and 1 deletions

View File

@ -10,6 +10,7 @@ import dictionariesRoutes from './dictionaries';
import docsRoutes from './docs';
import electricShapeRoutes from './electric-shape';
import githubRoutes from './github';
import libraryRoutes from './library';
import metricFilesRoutes from './metric_files';
import organizationRoutes from './organization';
import publicRoutes from './public';
@ -33,6 +34,7 @@ const app = new Hono()
.route('/electric-shape', electricShapeRoutes)
.route('/healthcheck', healthcheckRoutes)
.route('/chats', chatsRoutes)
.route('/library', libraryRoutes)
.route('/metric_files', metricFilesRoutes)
.route('/github', githubRoutes)
.route('/slack', slackRoutes)

View File

@ -0,0 +1,68 @@
import { checkPermission } from '@buster/access-controls';
import { bulkUpdateLibraryField, getUserOrganizationId } from '@buster/database/queries';
import type { LibraryDeleteRequestBody, LibraryDeleteResponse } from '@buster/server-shared';
import { LibraryDeleteRequestBodySchema } from '@buster/server-shared';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
const app = new Hono().delete(
'/',
zValidator('json', LibraryDeleteRequestBodySchema),
async (c) => {
const assets = c.req.valid('json');
const user = c.get('busterUser');
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User not associated with any organization' });
}
const failedAssets: LibraryDeleteResponse['failedItems'] = [];
const assetsToUpdate: LibraryDeleteRequestBody = [];
const permissionCheckPromises = assets.map((asset) =>
checkPermission({
userId: user.id,
assetId: asset.assetId,
assetType: asset.assetType,
organizationId: userOrg.organizationId,
requiredRole: 'full_access',
})
);
const permissionChecks = await Promise.all(permissionCheckPromises);
for (let i = 0; i < assets.length; i++) {
const permissionCheck = permissionChecks[i];
const asset = assets[i];
if (!asset || !permissionCheck) {
continue;
}
if (permissionCheck.hasAccess) {
assetsToUpdate.push(asset);
} else {
failedAssets.push({
assetId: asset.assetId,
assetType: asset.assetType,
error: 'User does not have permission to save to library',
});
}
}
const updateResult = await bulkUpdateLibraryField(assetsToUpdate, false);
const success = updateResult.success && failedAssets.length === 0;
const output: LibraryDeleteResponse = {
success,
successItems: updateResult.successItems,
failedItems: [...failedAssets, ...updateResult.failedItems],
};
return c.json(output);
}
);
export default app;

View File

@ -0,0 +1,40 @@
import { getUserOrganizationId, listPermissionedLibraryAssets } from '@buster/database/queries';
import { GetLibraryAssetsRequestQuerySchema, type LibraryGetResponse } from '@buster/server-shared';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
const app = new Hono().get(
'/',
zValidator('query', GetLibraryAssetsRequestQuerySchema),
async (c) => {
const { page, page_size, assetTypes, endDate, startDate, includeCreatedBy, excludeCreatedBy } =
c.req.valid('query');
const user = c.get('busterUser');
// Get user's organization
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg?.organizationId) {
throw new HTTPException(403, { message: 'User not associated with any organization' });
}
try {
const response: LibraryGetResponse = await listPermissionedLibraryAssets({
userId: user.id,
organizationId: userOrg.organizationId,
page,
page_size,
assetTypes,
endDate,
startDate,
includeCreatedBy,
excludeCreatedBy,
});
return c.json(response);
} catch (error) {
console.error('Error while listing permissioned library assets:', error);
throw new HTTPException(500, { message: 'Error while listing library assets' });
}
}
);
export default app;

View File

@ -0,0 +1,66 @@
import { checkPermission } from '@buster/access-controls';
import { bulkUpdateLibraryField, getUserOrganizationId } from '@buster/database/queries';
import type { LibraryAssetIdentifier } from '@buster/database/schema-types';
import {
type LibraryPostRequestBody,
LibraryPostRequestBodySchema,
type LibraryPostResponse,
} from '@buster/server-shared';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
const app = new Hono().post('/', zValidator('json', LibraryPostRequestBodySchema), async (c) => {
const assets = c.req.valid('json');
const user = c.get('busterUser');
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User not associated with any organization' });
}
const failedAssets: LibraryPostResponse['failedItems'] = [];
const assetsToSave: LibraryPostRequestBody = [];
// User needs direct full_access permissions to save to library
const permissionCheckPromises = assets.map((asset) =>
checkPermission({
userId: user.id,
assetId: asset.assetId,
assetType: asset.assetType,
organizationId: userOrg.organizationId,
requiredRole: 'full_access',
})
);
const permissionChecks = await Promise.all(permissionCheckPromises);
for (let i = 0; i < assets.length; i++) {
const permissionCheck = permissionChecks[i];
const asset = assets[i];
if (permissionCheck && asset) {
if (permissionCheck.hasAccess) {
assetsToSave.push(asset);
} else {
failedAssets.push({
assetId: asset.assetId,
assetType: asset.assetType,
error: 'User does not have permission to save to library',
});
}
}
}
const savedAssetResponse = await bulkUpdateLibraryField(assetsToSave, true);
const success = savedAssetResponse.success && failedAssets.length === 0;
const output: LibraryPostResponse = {
success,
successItems: savedAssetResponse.successItems,
failedItems: [...failedAssets, ...savedAssetResponse.failedItems],
};
return c.json(output);
});
export default app;

View File

@ -0,0 +1,15 @@
import { Hono } from 'hono';
import { requireAuth } from '../../../middleware/auth';
import { standardErrorHandler } from '../../../utils/response';
import DELETE from './DELETE';
import GET from './GET';
import POST from './POST';
const app = new Hono()
.use('*', requireAuth)
.route('/', GET)
.route('/', POST)
.route('/', DELETE)
.onError(standardErrorHandler);
export default app;

View File

@ -101,6 +101,7 @@ describe('metric-helpers', () => {
workspaceSharingEnabledBy: null,
deletedAt: null,
screenshotBucketKey: null,
savedToLibrary: false,
...overrides,
});

View File

@ -0,0 +1,4 @@
ALTER TABLE "chats" ADD COLUMN "saved_to_library" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "dashboard_files" ADD COLUMN "saved_to_library" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "metric_files" ADD COLUMN "saved_to_library" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "report_files" ADD COLUMN "saved_to_library" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -820,6 +820,13 @@
"when": 1759357134333,
"tag": "0117_careful_alex_power",
"breakpoints": true
},
{
"idx": 118,
"version": "7",
"when": 1759428123492,
"tag": "0118_violet_vin_gonzales",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,74 @@
import { and, db, eq, isNull } from '../../connection';
import { chats, dashboardFiles, metricFiles, reportFiles } from '../../schema';
import type {
BulkUpdateLibraryFieldInput,
BulkUpdateLibraryFieldResponse,
LibraryAssetType,
} from '../../schema-types';
type LibraryAssetTable =
| typeof chats
| typeof dashboardFiles
| typeof metricFiles
| typeof reportFiles;
export const libraryAssetTableMap: Record<LibraryAssetType, LibraryAssetTable> = {
chat: chats,
dashboard_file: dashboardFiles,
metric_file: metricFiles,
report_file: reportFiles,
};
export async function bulkUpdateLibraryField(
input: BulkUpdateLibraryFieldInput,
savedToLibrary: boolean
): Promise<BulkUpdateLibraryFieldResponse> {
const failedItems: BulkUpdateLibraryFieldResponse['failedItems'] = [];
const successItems: BulkUpdateLibraryFieldResponse['successItems'] = [];
const promises: Promise<void>[] = [];
for (const asset of input) {
promises.push(updateAssetLibraryField(asset.assetId, asset.assetType, savedToLibrary));
}
const results = await Promise.allSettled(promises);
for (let i = 0; i < results.length; i++) {
const result = results[i];
const asset = input[i];
if (asset && result) {
if (result.status === 'fulfilled') {
successItems.push(asset);
} else {
failedItems.push({
...asset,
error: result.reason?.message || 'Unknown error',
});
}
}
}
const success = failedItems.length === 0;
return {
success,
successItems,
failedItems,
};
}
async function updateAssetLibraryField(
assetId: string,
assetType: LibraryAssetType,
savedToLibrary: boolean
): Promise<void> {
const table = libraryAssetTableMap[assetType];
await db
.update(table)
.set({
savedToLibrary,
updatedAt: new Date().toISOString(),
})
.where(and(eq(table.id, assetId), isNull(table.deletedAt)));
}

View File

@ -53,3 +53,7 @@ export {
GetAssetScreenshotBucketKeyInputSchema,
type GetAssetScreenshotBucketKeyInput,
} from './get-asset-screenshot-bucket-key';
export { bulkUpdateLibraryField } from './bulk-update-asset-library-field';
export { listPermissionedLibraryAssets } from './list-permissioned-library-assets';

View File

@ -0,0 +1,278 @@
import {
type SQL,
and,
count,
desc,
eq,
exists,
gte,
inArray,
isNull,
lte,
ne,
not,
or,
sql,
} from 'drizzle-orm';
import { db } from '../../connection';
import {
assetPermissions,
chats,
dashboardFiles,
metricFiles,
reportFiles,
users,
} from '../../schema';
import {
type LibraryAssetListItem,
type LibraryAssetType,
type ListPermissionedLibraryAssetsInput,
ListPermissionedLibraryAssetsInputSchema,
type ListPermissionedLibraryAssetsResponse,
createPaginatedResponse,
} from '../../schema-types';
export async function listPermissionedLibraryAssets(
input: ListPermissionedLibraryAssetsInput
): Promise<ListPermissionedLibraryAssetsResponse> {
const {
organizationId,
userId,
assetTypes,
createdById,
startDate,
endDate,
includeCreatedBy,
excludeCreatedBy,
page,
page_size,
} = ListPermissionedLibraryAssetsInputSchema.parse(input);
const offset = (page - 1) * page_size;
const permissionedReportFiles = db
.select({
assetId: reportFiles.id,
assetType: sql`'report_file'::asset_type_enum`.as('assetType'),
name: reportFiles.name,
createdAt: reportFiles.createdAt,
updatedAt: reportFiles.updatedAt,
createdBy: reportFiles.createdBy,
organizationId: reportFiles.organizationId,
})
.from(reportFiles)
.where(
and(
eq(reportFiles.organizationId, organizationId),
eq(reportFiles.savedToLibrary, true),
isNull(reportFiles.deletedAt),
or(
ne(reportFiles.workspaceSharing, 'none'),
exists(
db
.select({ value: sql`1` })
.from(assetPermissions)
.where(
and(
eq(assetPermissions.assetId, reportFiles.id),
eq(assetPermissions.assetType, 'report_file'),
eq(assetPermissions.identityId, userId),
eq(assetPermissions.identityType, 'user'),
isNull(assetPermissions.deletedAt)
)
)
)
)
)
);
const permissionedMetricFiles = db
.select({
assetId: metricFiles.id,
assetType: sql`'metric_file'::asset_type_enum`.as('assetType'),
name: metricFiles.name,
createdAt: metricFiles.createdAt,
updatedAt: metricFiles.updatedAt,
createdBy: metricFiles.createdBy,
organizationId: metricFiles.organizationId,
})
.from(metricFiles)
.where(
and(
eq(metricFiles.organizationId, organizationId),
eq(metricFiles.savedToLibrary, true),
isNull(metricFiles.deletedAt),
or(
ne(metricFiles.workspaceSharing, 'none'),
exists(
db
.select({ value: sql`1` })
.from(assetPermissions)
.where(
and(
eq(assetPermissions.assetId, metricFiles.id),
eq(assetPermissions.assetType, 'metric_file'),
eq(assetPermissions.identityId, userId),
eq(assetPermissions.identityType, 'user'),
isNull(assetPermissions.deletedAt)
)
)
)
)
)
);
const permissionedDashboardFiles = db
.select({
assetId: dashboardFiles.id,
assetType: sql`'dashboard_file'::asset_type_enum`.as('assetType'),
name: dashboardFiles.name,
createdAt: dashboardFiles.createdAt,
updatedAt: dashboardFiles.updatedAt,
createdBy: dashboardFiles.createdBy,
organizationId: dashboardFiles.organizationId,
})
.from(dashboardFiles)
.where(
and(
eq(dashboardFiles.organizationId, organizationId),
eq(dashboardFiles.savedToLibrary, true),
isNull(dashboardFiles.deletedAt),
or(
ne(dashboardFiles.workspaceSharing, 'none'),
exists(
db
.select({ value: sql`1` })
.from(assetPermissions)
.where(
and(
eq(assetPermissions.assetId, dashboardFiles.id),
eq(assetPermissions.assetType, 'dashboard_file'),
eq(assetPermissions.identityId, userId),
eq(assetPermissions.identityType, 'user'),
isNull(assetPermissions.deletedAt)
)
)
)
)
)
);
const permissionedChats = db
.select({
assetId: chats.id,
assetType: sql`'chat'::asset_type_enum`.as('assetType'),
name: chats.title,
createdAt: chats.createdAt,
updatedAt: chats.updatedAt,
createdBy: chats.createdBy,
organizationId: chats.organizationId,
})
.from(chats)
.where(
and(
eq(chats.organizationId, organizationId),
eq(chats.savedToLibrary, true),
isNull(chats.deletedAt),
or(
ne(chats.workspaceSharing, 'none'),
exists(
db
.select({ value: sql`1` })
.from(assetPermissions)
.where(
and(
eq(assetPermissions.assetId, chats.id),
eq(assetPermissions.assetType, 'chat'),
eq(assetPermissions.identityId, userId),
eq(assetPermissions.identityType, 'user'),
isNull(assetPermissions.deletedAt)
)
)
)
)
)
);
const permissionedAssets = permissionedReportFiles
.union(permissionedMetricFiles)
.union(permissionedDashboardFiles)
.union(permissionedChats)
.as('permissioned_assets');
const filters: SQL[] = [];
if (assetTypes && assetTypes.length > 0) {
filters.push(inArray(permissionedAssets.assetType, assetTypes));
}
if (createdById) {
filters.push(eq(permissionedAssets.createdBy, createdById));
}
if (startDate) {
filters.push(gte(permissionedAssets.createdAt, startDate));
}
if (endDate) {
filters.push(lte(permissionedAssets.createdAt, endDate));
}
if (includeCreatedBy && includeCreatedBy.length > 0) {
filters.push(inArray(permissionedAssets.createdBy, includeCreatedBy));
}
if (excludeCreatedBy && excludeCreatedBy.length > 0) {
filters.push(not(inArray(permissionedAssets.createdBy, excludeCreatedBy)));
}
const whereCondition =
filters.length === 0 ? undefined : filters.length === 1 ? filters[0] : and(...filters);
const baseAssetQuery = db
.select({
assetId: permissionedAssets.assetId,
assetType: permissionedAssets.assetType,
name: permissionedAssets.name,
updatedAt: permissionedAssets.updatedAt,
createdAt: permissionedAssets.createdAt,
createdBy: permissionedAssets.createdBy,
createdByName: users.name,
createdByEmail: users.email,
createdByAvatarUrl: users.avatarUrl,
})
.from(permissionedAssets)
.innerJoin(users, eq(permissionedAssets.createdBy, users.id));
const filteredAssetQuery = whereCondition ? baseAssetQuery.where(whereCondition) : baseAssetQuery;
const assetsResult = await filteredAssetQuery
.orderBy(desc(permissionedAssets.createdAt))
.limit(page_size)
.offset(offset);
const baseCountQuery = db.select({ total: count() }).from(permissionedAssets);
const countResult = await (whereCondition
? baseCountQuery.where(whereCondition)
: baseCountQuery);
const totalValue = countResult[0]?.total ?? 0;
const libraryAssets: LibraryAssetListItem[] = assetsResult.map((asset) => ({
asset_id: asset.assetId,
asset_type: asset.assetType as LibraryAssetType,
name: asset.name ?? '',
created_at: asset.createdAt,
updated_at: asset.updatedAt,
created_by: asset.createdBy,
created_by_name: asset.createdByName,
created_by_email: asset.createdByEmail,
created_by_avatar_url: asset.createdByAvatarUrl,
}));
return createPaginatedResponse({
data: libraryAssets,
page,
page_size,
total: totalValue,
});
}

View File

@ -21,7 +21,7 @@ type ScreenshotTable =
| typeof metricFiles
| typeof reportFiles;
const assetTableMap: Record<AssetType, ScreenshotTable> = {
export const assetTableMap: Record<AssetType, ScreenshotTable> = {
chat: chats,
collection: collections,
dashboard_file: dashboardFiles,

View File

@ -39,3 +39,5 @@ export * from './search';
export * from './chat';
export * from './pagination';
export * from './library';

View File

@ -0,0 +1,60 @@
import z from 'zod';
import { AssetTypeSchema } from './asset';
import { type PaginatedResponse, PaginationInputSchema } from './pagination';
export const LibraryAssetTypeSchema = AssetTypeSchema.exclude(['collection']);
export type LibraryAssetType = z.infer<typeof LibraryAssetTypeSchema>;
export const LibraryAssetIdentifierSchema = z.object({
assetId: z.string().uuid(),
assetType: LibraryAssetTypeSchema,
});
export type LibraryAssetIdentifier = z.infer<typeof LibraryAssetIdentifierSchema>;
export const BulkUpdateLibraryFieldInputSchema = z.array(LibraryAssetIdentifierSchema);
export type BulkUpdateLibraryFieldInput = z.infer<typeof BulkUpdateLibraryFieldInputSchema>;
export const BulkUpdateLibraryFieldResponseSchema = z.object({
success: z.boolean(),
successItems: z.array(LibraryAssetIdentifierSchema),
failedItems: z.array(
LibraryAssetIdentifierSchema.extend({
error: z.string(),
})
),
});
export type BulkUpdateLibraryFieldResponse = z.infer<typeof BulkUpdateLibraryFieldResponseSchema>;
export const LibraryAssetListItemSchema = z.object({
asset_id: z.string().uuid(),
asset_type: LibraryAssetTypeSchema,
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
created_by: z.string().uuid(),
created_by_name: z.string().nullable(),
created_by_email: z.string(),
created_by_avatar_url: z.string().nullable(),
});
export type LibraryAssetListItem = z.infer<typeof LibraryAssetListItemSchema>;
export const ListPermissionedLibraryAssetsInputSchema = z
.object({
organizationId: z.string().uuid(),
userId: z.string().uuid(),
assetTypes: LibraryAssetTypeSchema.array().min(1).optional(),
createdById: z.string().uuid().optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
includeCreatedBy: z.string().uuid().array().optional(),
excludeCreatedBy: z.string().uuid().array().optional(),
})
.merge(PaginationInputSchema);
export type ListPermissionedLibraryAssetsInput = z.infer<
typeof ListPermissionedLibraryAssetsInputSchema
>;
export type ListPermissionedLibraryAssetsResponse = PaginatedResponse<LibraryAssetListItem>;

View File

@ -743,6 +743,7 @@ export const dashboardFiles = pgTable(
mode: 'string',
}),
screenshotBucketKey: text('screenshot_bucket_key'),
savedToLibrary: boolean('saved_to_library').default(false).notNull(),
},
(table) => [
index('dashboard_files_created_by_idx').using(
@ -817,6 +818,7 @@ export const reportFiles = pgTable(
mode: 'string',
}),
screenshotBucketKey: text('screenshot_bucket_key'),
savedToLibrary: boolean('saved_to_library').default(false).notNull(),
},
(table) => [
index('report_files_created_by_idx').using(
@ -889,6 +891,7 @@ export const chats = pgTable(
mode: 'string',
}),
screenshotBucketKey: text('screenshot_bucket_key'),
savedToLibrary: boolean('saved_to_library').default(false).notNull(),
},
(table) => [
index('chats_created_at_idx').using(
@ -1040,6 +1043,7 @@ export const metricFiles = pgTable(
mode: 'string',
}),
screenshotBucketKey: text('screenshot_bucket_key'),
savedToLibrary: boolean('saved_to_library').default(false).notNull(),
},
(table) => [
index('metric_files_created_by_idx').using(

View File

@ -107,6 +107,10 @@
"./screenshots": {
"types": "./dist/screenshots/index.d.ts",
"default": "./dist/screenshots/index.js"
},
"./library": {
"types": "./dist/library/index.d.ts",
"default": "./dist/library/index.js"
}
},
"module": "src/index.ts",

View File

@ -37,3 +37,4 @@ export * from './shortcuts';
export * from './healthcheck';
export * from './sql';
export * from './logs-writeback';
export * from './library';

View File

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

View File

@ -0,0 +1,48 @@
import {
type BulkUpdateLibraryFieldInput,
BulkUpdateLibraryFieldInputSchema,
LibraryAssetTypeSchema,
} from '@buster/database/schema-types';
import { z } from 'zod';
import { PaginatedRequestSchema } from '../type-utilities';
export const GetLibraryAssetsRequestQuerySchema = z
.object({
assetTypes: z
.preprocess((val) => {
if (typeof val === 'string') {
return [val];
}
return val;
}, LibraryAssetTypeSchema.array().min(1))
.optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
includeCreatedBy: z
.preprocess((val) => {
if (typeof val === 'string') {
return [val];
}
return val;
}, z.string().uuid().array())
.optional(),
excludeCreatedBy: z
.preprocess((val) => {
if (typeof val === 'string') {
return [val];
}
return val;
}, z.string().uuid().array())
.optional(),
})
.merge(PaginatedRequestSchema);
export type GetLibraryAssetsRequestQuery = z.infer<typeof GetLibraryAssetsRequestQuerySchema>;
export const LibraryPostRequestBodySchema = BulkUpdateLibraryFieldInputSchema;
export type LibraryPostRequestBody = BulkUpdateLibraryFieldInput;
export const LibraryDeleteRequestBodySchema = BulkUpdateLibraryFieldInputSchema;
export type LibraryDeleteRequestBody = BulkUpdateLibraryFieldInput;

View File

@ -0,0 +1,15 @@
import {
type BulkUpdateLibraryFieldResponse,
BulkUpdateLibraryFieldResponseSchema,
type ListPermissionedLibraryAssetsResponse,
} from '@buster/database/schema-types';
export type LibraryGetResponse = ListPermissionedLibraryAssetsResponse;
export const LibraryPostResponseSchema = BulkUpdateLibraryFieldResponseSchema;
export type LibraryPostResponse = BulkUpdateLibraryFieldResponse;
export const LibraryDeleteResponseSchema = BulkUpdateLibraryFieldResponseSchema;
export type LibraryDeleteResponse = BulkUpdateLibraryFieldResponse;