get rid of migration

This commit is contained in:
dal 2025-09-30 08:29:59 -06:00
commit 6b86f561aa
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
49 changed files with 7962 additions and 170 deletions

View File

@ -1,24 +0,0 @@
# AGENT.md
## Commands
- **Build**: `turbo build` or `turbo run build:dry-run` (type check only)
- **Tests**: `turbo run test:unit` (run before completing tasks), `turbo run test:integration --filter=<package>`
- **Single test**: `turbo run test:unit --filter=<package>` or run test files directly in specific packages
- **Lint**: `turbo lint`, `pnpm run check:fix <path>` (auto-fixes with Biome)
- **Pre-completion check**: `turbo run build:dry-run lint test:unit`
## Architecture
**Monorepo**: pnpm + Turborepo with `@buster/*` packages and `@buster-app/*` apps
- **Apps**: web (Next.js), server (Hono API), trigger (background jobs), electric-server, api (Rust legacy), cli (Rust)
- **Key packages**: ai (Mastra framework), database (Drizzle ORM), server-shared (API types), data-source, access-controls
- **Database**: PostgreSQL with Supabase, soft deletes only (`deleted_at`), queries in `@buster/database/src/queries/`
- **APIs**: Hono with functional handlers, type-safe with Zod schemas in `@buster/server-shared`
## Code Style
- **TypeScript**: Strict mode, no `any`, handle null/undefined explicitly
- **Imports**: Use type-only imports (`import type`), Node.js protocol (`node:fs`)
- **Formatting**: Biome - 2 spaces, single quotes, trailing commas, 100 char width
- **Functions**: Functional/composable over classes, dependency injection, small focused functions
- **Logging**: Never `console.log`, use `console.info/warn/error`
- **Naming**: `@buster/{package}` for packages, `@buster-app/{app}` for apps
- **Error handling**: Comprehensive with strategic logging, soft deletes, upserts preferred

136
AGENTS.md Normal file
View File

@ -0,0 +1,136 @@
# CLAUDE.md
This file provides core guidance to Claude/AI assistants when working with the Buster monorepo.
**Note**: Each package and app has its own CLAUDE.md with specific implementation details. This document contains only universal principles.
## Monorepo Philosophy
### Architecture Principles
1. **Packages are standalone building blocks** - Modular components with minimal cross-dependencies
2. **Apps assemble packages** - Apps piece together package code, never contain business logic directly
3. **Avoid spaghetti dependencies** - Keep clean boundaries between packages
4. **Type flow hierarchy** - Types flow: `database``server-shared``apps`
### Critical Package Boundaries
- **`@buster/database`** - Owns ALL database queries. No direct Drizzle usage elsewhere
- **`@buster/server-shared`** - API contract layer. All request/response types live here
- **`@buster/data-source`** - Isolated data source connection logic for customer databases
- **Package imports** - Packages can use each other but maintain clear, logical dependencies
## Development Principles
### Functional Programming First
- **Pure functions only** - No classes for business logic
- **Composable modules** - Build features by composing small, focused functions
- **Immutable data** - Never mutate; always create new data structures
- **Higher-order functions** - Use functions that return configured functions for dependency injection
- **No OOP** - No classes, no inheritance, no `this` keyword in business logic
### Type Safety Standards
- **Zod-first everything** - Define ALL types as Zod schemas with descriptions
- **Export inferred types** - Always use `z.infer<typeof Schema>` for TypeScript types
- **Runtime validation** - Use `.parse()` for trusted data, `.safeParse()` for user input
- **No implicit any** - Every variable, parameter, and return type must be explicitly typed
- **Constants for strings** - Use const assertions for type-safe string literals
### Testing Philosophy
- **Test-driven development** - Write tests and assertions first, then implement
- **Colocate tests** - Keep `.test.ts` (unit) and `.int.test.ts` (integration) next to implementation
- **Test naming** - If file is `user.ts`, tests are `user.test.ts` and/or `user.int.test.ts`
- **Minimize integration dependencies** - Most logic should be testable with unit tests
- **Test descriptions** - Test names should describe the assertion and situation clearly
## Development Workflow
### Command Standards
**CRITICAL**: Only use Turbo commands. Never use pnpm, npm, or vitest directly.
```bash
# Build commands
turbo build # Build entire monorepo
turbo build --filter=@buster/ai # Build specific package
# Linting
turbo lint # Lint entire monorepo
turbo lint --filter=@buster-app/web # Lint specific app
# Testing
turbo test:unit # Run all unit tests
turbo test:unit --filter=@buster/database # Test specific package
turbo test:integration --filter=@buster/ai # Integration tests for specific package
# Development
turbo dev # Start development servers
```
### Pre-Completion Checklist
Before completing any task:
1. Run `turbo build` - Ensure everything compiles
2. Run `turbo lint` - Fix all linting issues
3. Run `turbo test:unit` - All unit tests must pass
## Code Organization
### File Structure
- **Small, focused files** - Each file has a single responsibility
- **Deep nesting is OK** - Organize into logical subdirectories
- **Explicit exports** - Use named exports and comprehensive index.ts files
- **Functional patterns** - Export factory functions that return configured function sets
### Module Patterns
```typescript
// Good: Functional approach with Zod
import { z } from 'zod';
const UserParamsSchema = z.object({
userId: z.string().describe('Unique user identifier'),
orgId: z.string().describe('Organization identifier')
});
type UserParams = z.infer<typeof UserParamsSchema>;
export function validateUser(params: UserParams) {
const validated = UserParamsSchema.parse(params);
// Implementation
}
// Bad: Class-based approach
class UserService { // Never do this
validateUser() { }
}
```
## Cross-Cutting Concerns
### Environment Variables
- Centralized at root level in `.env` file
- Turbo passes variables via `globalEnv` configuration
- Individual packages validate their required variables
### Database Operations
- ALL queries go through `@buster/database` package
- Never use Drizzle directly outside the database package
- Soft deletes only (use `deleted_at` field)
- Prefer upserts over updates
### API Development
- Request/response types in `@buster/server-shared`
- Import database types through server-shared for consistency
- Validate with Zod at API boundaries
- Use type imports: `import type { User } from '@buster/database'`
## Legacy Code Migration
- **Rust code** (`apps/api`) is legacy and being migrated to TypeScript
- Focus new development on TypeScript patterns
- Follow patterns in `apps/server` for new API development
## Agent Workflows
- Use `planner` agent for spec, plan, ticket, research development workflows.
## Important Reminders
- Do only what has been asked; nothing more, nothing less
- Never create files unless absolutely necessary
- Always prefer editing existing files over creating new ones
- Never proactively create documentation unless explicitly requested
- Check package/app-specific CLAUDE.md files for implementation details

View File

@ -1,8 +1,10 @@
import { Hono } from 'hono';
import GET from './GET';
import SHARING from './sharing';
const app = new Hono();
app.route('/', GET);
app.route('/sharing', SHARING);
export default app;

View File

@ -0,0 +1,88 @@
import { checkPermission } from '@buster/access-controls';
import { findUsersByEmails, getChatById, removeAssetPermission } from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareDeleteRequest, ShareDeleteResponse } from '@buster/server-shared/share';
import { ShareDeleteRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function deleteChatSharingHandler(
chatId: string,
emails: ShareDeleteRequest,
user: User
): Promise<ShareDeleteResponse> {
// Get the chat to verify it exists and get owner info
const chat = await getChatById(chatId);
if (!chat) {
throw new HTTPException(404, { message: 'Chat not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: chatId,
assetType: 'chat',
requiredRole: 'full_access',
workspaceSharing: chat.workspaceSharing,
organizationId: chat.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to delete sharing for this chat',
});
}
// Find users by emails
const userMap = await findUsersByEmails(emails);
const removedEmails = [];
const notFoundEmails = [];
for (const email of emails) {
const targetUser = userMap.get(email);
if (!targetUser) {
notFoundEmails.push(email);
continue;
}
// Don't allow removing permissions from the owner
if (targetUser.id === chat.createdBy) {
continue; // Skip the owner
}
// Remove the permission
await removeAssetPermission({
identityId: targetUser.id,
identityType: 'user',
assetId: chatId,
assetType: 'chat',
updatedBy: user.id,
});
removedEmails.push(email);
}
return {
success: true,
removed: removedEmails,
notFound: notFoundEmails,
};
}
const app = new Hono().delete('/', zValidator('json', ShareDeleteRequestSchema), async (c) => {
const chatId = c.req.param('id');
const emails = c.req.valid('json');
const user = c.get('busterUser');
if (!chatId) {
throw new HTTPException(400, { message: 'Chat ID is required' });
}
const result = await deleteChatSharingHandler(chatId, emails, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,55 @@
import { checkPermission } from '@buster/access-controls';
import { checkAssetPermission, getChatById, listAssetPermissions } from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareGetResponse } from '@buster/server-shared/reports';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function getChatSharingHandler(chatId: string, user: User): Promise<ShareGetResponse> {
// Check if chat exists
const chat = await getChatById(chatId);
if (!chat) {
throw new HTTPException(404, { message: 'Chat not found' });
}
// Check if user has permission to view the chat
const permissionCheck = await checkPermission({
userId: user.id,
assetId: chatId,
assetType: 'chat',
requiredRole: 'can_view',
workspaceSharing: chat.workspaceSharing,
organizationId: chat.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to view this chat',
});
}
// Get all permissions for the chat
const permissions = await listAssetPermissions({
assetId: chatId,
assetType: 'chat',
});
return {
permissions,
};
}
const app = new Hono().get('/', async (c) => {
const chatId = c.req.param('id');
const user = c.get('busterUser');
if (!chatId) {
throw new HTTPException(400, { message: 'Chat ID is required' });
}
const result = await getChatSharingHandler(chatId, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,113 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getChatById,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { SharePostRequest, SharePostResponse } from '@buster/server-shared/share';
import { SharePostRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function createChatSharingHandler(
chatId: string,
shareRequests: SharePostRequest,
user: User
): Promise<SharePostResponse> {
// Get the chat to verify it exists
const chat = await getChatById(chatId);
if (!chat) {
throw new HTTPException(404, { message: 'Chat not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: chatId,
assetType: 'chat',
requiredRole: 'can_edit',
workspaceSharing: chat.workspaceSharing,
organizationId: chat.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to edit this chat',
});
}
// Extract emails from the share requests
const emails = shareRequests.map((req) => req.email);
// Find users by emails
const userMap = await findUsersByEmails(emails);
const permissions = [];
const sharedEmails = [];
const notFoundEmails = [];
for (const shareRequest of shareRequests) {
const targetUser = userMap.get(shareRequest.email);
if (!targetUser) {
notFoundEmails.push(shareRequest.email);
continue;
}
sharedEmails.push(shareRequest.email);
// Map ShareRole to AssetPermissionRole
const roleMapping = {
owner: 'owner',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
viewer: 'can_view', // Map viewer to can_view
} as const;
const mappedRole = roleMapping[shareRequest.role];
if (!mappedRole) {
throw new HTTPException(400, {
message: `Invalid role: ${shareRequest.role} for user ${shareRequest.email}`,
});
}
permissions.push({
identityId: targetUser.id,
identityType: 'user' as const,
assetId: chatId,
assetType: 'chat' as const,
role: mappedRole,
createdBy: user.id,
});
}
// Create permissions in bulk
if (permissions.length > 0) {
await bulkCreateAssetPermissions({ permissions });
}
return {
success: true,
shared: sharedEmails,
notFound: notFoundEmails,
};
}
const app = new Hono().post('/', zValidator('json', SharePostRequestSchema), async (c) => {
const chatId = c.req.param('id');
const shareRequests = c.req.valid('json');
const user = c.get('busterUser');
if (!chatId) {
throw new HTTPException(400, { message: 'Chat ID is required' });
}
const result = await createChatSharingHandler(chatId, shareRequests, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,140 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getChatById,
getUserOrganizationId,
updateChatSharing,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { GetChatResponse } from '@buster/server-shared/chats';
import { type ShareUpdateRequest, ShareUpdateRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { getChatHandler } from '../GET';
export async function updateChatShareHandler(
chatId: string,
request: ShareUpdateRequest,
user: User & { organizationId: string }
) {
// Check if chat exists
const chat = await getChatById(chatId);
if (!chat) {
throw new HTTPException(404, { message: 'Chat not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: chatId,
assetType: 'chat',
requiredRole: 'full_access',
workspaceSharing: chat.workspaceSharing,
organizationId: chat.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to update sharing for this chat',
});
}
const { publicly_accessible, public_expiry_date, workspace_sharing, users } = request;
// Handle user permissions if provided
if (users && users.length > 0) {
// Extract emails from the user permissions
const emails = users.map((u) => u.email);
// Find users by emails
const userMap = await findUsersByEmails(emails);
const permissions = [];
for (const userPermission of users) {
const targetUser = userMap.get(userPermission.email);
if (!targetUser) {
// Skip users that don't exist - you may want to collect these and return as warnings
continue;
}
// Map ShareRole to AssetPermissionRole
const roleMapping = {
owner: 'owner',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
viewer: 'can_view', // Map viewer to can_view
} as const;
const mappedRole = roleMapping[userPermission.role];
if (!mappedRole) {
throw new HTTPException(400, {
message: `Invalid role: ${userPermission.role} for user ${userPermission.email}`,
});
}
permissions.push({
identityId: targetUser.id,
identityType: 'user' as const,
assetId: chatId,
assetType: 'chat' as const,
role: mappedRole,
createdBy: user.id,
});
}
// Create/update permissions in bulk
if (permissions.length > 0) {
await bulkCreateAssetPermissions({ permissions });
}
}
// Update chat sharing settings - only pass defined values
const updateOptions: Parameters<typeof updateChatSharing>[2] = {};
if (publicly_accessible !== undefined) {
updateOptions.publicly_accessible = publicly_accessible;
}
if (public_expiry_date !== undefined) {
updateOptions.public_expiry_date = public_expiry_date;
}
if (workspace_sharing !== undefined) {
updateOptions.workspace_sharing = workspace_sharing;
}
await updateChatSharing(chatId, user.id, updateOptions);
const updatedChat: GetChatResponse = await getChatHandler({
chatId,
user,
});
return updatedChat;
}
const app = new Hono().put('/', zValidator('json', ShareUpdateRequestSchema), async (c) => {
const chatId = c.req.param('id');
const request = c.req.valid('json');
const user = c.get('busterUser');
if (!chatId) {
throw new HTTPException(404, { message: 'Chat not found' });
}
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User is not associated with an organization' });
}
const updatedChat: GetChatResponse = await updateChatShareHandler(chatId, request, {
...user,
organizationId: userOrg.organizationId,
});
return c.json(updatedChat);
});
export default app;

View File

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

View File

@ -1,9 +1,11 @@
import { Hono } from 'hono';
import '../../../../types/hono.types';
import dashboardByIdRoutes from './GET';
import SHARING from './sharing';
const app = new Hono()
// /dashboards/:id GET
.route('/', dashboardByIdRoutes);
.route('/', dashboardByIdRoutes)
.route('/sharing', SHARING);
export default app;

View File

@ -0,0 +1,93 @@
import { checkPermission } from '@buster/access-controls';
import {
findUsersByEmails,
getDashboardById,
removeAssetPermission,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareDeleteResponse } from '@buster/server-shared/share';
import type { ShareDeleteRequest } from '@buster/server-shared/share';
import { ShareDeleteRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function deleteDashboardSharingHandler(
dashboardId: string,
emails: ShareDeleteRequest,
user: User
): Promise<ShareDeleteResponse> {
// Get the dashboard to verify it exists and get owner info
const dashboard = await getDashboardById({ dashboardId });
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: dashboardId,
assetType: 'dashboard_file',
requiredRole: 'full_access',
workspaceSharing: dashboard.workspaceSharing,
organizationId: dashboard.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to delete sharing for this dashboard',
});
}
// Find users by emails
const userMap = await findUsersByEmails(emails);
const removedEmails = [];
const notFoundEmails = [];
for (const email of emails) {
const targetUser = userMap.get(email);
if (!targetUser) {
notFoundEmails.push(email);
continue;
}
// Don't allow removing permissions from the owner
if (targetUser.id === dashboard.createdBy) {
continue; // Skip the owner
}
// Remove the permission
await removeAssetPermission({
identityId: targetUser.id,
identityType: 'user',
assetId: dashboardId,
assetType: 'dashboard_file',
updatedBy: user.id,
});
removedEmails.push(email);
}
return {
success: true,
removed: removedEmails,
notFound: notFoundEmails,
};
}
const app = new Hono().delete('/', zValidator('json', ShareDeleteRequestSchema), async (c) => {
const dashboardId = c.req.param('id');
const emails = c.req.valid('json');
const user = c.get('busterUser');
if (!dashboardId) {
throw new HTTPException(400, { message: 'Dashboard ID is required' });
}
const result = await deleteDashboardSharingHandler(dashboardId, emails, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,58 @@
import { checkPermission } from '@buster/access-controls';
import { getDashboardById, listAssetPermissions } from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareGetResponse } from '@buster/server-shared/reports';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function getDashboardSharingHandler(
dashboardId: string,
user: User
): Promise<ShareGetResponse> {
// Check if dashboard exists
const dashboard = await getDashboardById({ dashboardId });
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
// Check if user has permission to view the dashboard
const permissionCheck = await checkPermission({
userId: user.id,
assetId: dashboardId,
assetType: 'dashboard_file',
requiredRole: 'can_view',
workspaceSharing: dashboard.workspaceSharing,
organizationId: dashboard.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to view this dashboard',
});
}
// Get all permissions for the dashboard
const permissions = await listAssetPermissions({
assetId: dashboardId,
assetType: 'dashboard_file',
});
return {
permissions,
};
}
const app = new Hono().get('/', async (c) => {
const dashboardId = c.req.param('id');
const user = c.get('busterUser');
if (!dashboardId) {
throw new HTTPException(400, { message: 'Dashboard ID is required' });
}
const result = await getDashboardSharingHandler(dashboardId, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,114 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getDashboardById,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { SharePostResponse } from '@buster/server-shared/share';
import type { SharePostRequest } from '@buster/server-shared/share';
import { SharePostRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function createDashboardSharingHandler(
dashboardId: string,
shareRequests: SharePostRequest,
user: User
): Promise<SharePostResponse> {
// Get the dashboard to verify it exists
const dashboard = await getDashboardById({ dashboardId });
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: dashboardId,
assetType: 'dashboard_file',
requiredRole: 'can_edit',
workspaceSharing: dashboard.workspaceSharing,
organizationId: dashboard.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to edit this dashboard',
});
}
// Extract emails from the share requests
const emails = shareRequests.map((req) => req.email);
// Find users by emails
const userMap = await findUsersByEmails(emails);
const permissions = [];
const sharedEmails = [];
const notFoundEmails = [];
for (const shareRequest of shareRequests) {
const targetUser = userMap.get(shareRequest.email);
if (!targetUser) {
notFoundEmails.push(shareRequest.email);
continue;
}
sharedEmails.push(shareRequest.email);
// Map ShareRole to AssetPermissionRole
const roleMapping = {
owner: 'owner',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
viewer: 'can_view', // Map viewer to can_view
} as const;
const mappedRole = roleMapping[shareRequest.role];
if (!mappedRole) {
throw new HTTPException(400, {
message: `Invalid role: ${shareRequest.role} for user ${shareRequest.email}`,
});
}
permissions.push({
identityId: targetUser.id,
identityType: 'user' as const,
assetId: dashboardId,
assetType: 'dashboard_file' as const,
role: mappedRole,
createdBy: user.id,
});
}
// Create permissions in bulk
if (permissions.length > 0) {
await bulkCreateAssetPermissions({ permissions });
}
return {
success: true,
shared: sharedEmails,
notFound: notFoundEmails,
};
}
const app = new Hono().post('/', zValidator('json', SharePostRequestSchema), async (c) => {
const dashboardId = c.req.param('id');
const shareRequests = c.req.valid('json');
const user = c.get('busterUser');
if (!dashboardId) {
throw new HTTPException(400, { message: 'Dashboard ID is required' });
}
const result = await createDashboardSharingHandler(dashboardId, shareRequests, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,139 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getDashboardById,
getUserOrganizationId,
updateDashboard,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { GetDashboardResponse } from '@buster/server-shared/dashboards';
import { type ShareUpdateRequest, ShareUpdateRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { getDashboardHandler } from '../GET';
export async function updateDashboardShareHandler(
dashboardId: string,
request: ShareUpdateRequest,
user: User & { organizationId: string }
) {
// Check if dashboard exists
const dashboard = await getDashboardById({ dashboardId });
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: dashboardId,
assetType: 'dashboard_file',
requiredRole: 'full_access',
workspaceSharing: dashboard.workspaceSharing,
organizationId: dashboard.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to update sharing for this dashboard',
});
}
const { publicly_accessible, public_expiry_date, public_password, workspace_sharing, users } =
request;
// Handle user permissions if provided
if (users && users.length > 0) {
// Extract emails from the user permissions
const emails = users.map((u) => u.email);
// Find users by emails
const userMap = await findUsersByEmails(emails);
const permissions = [];
for (const userPermission of users) {
const targetUser = userMap.get(userPermission.email);
if (!targetUser) {
// Skip users that don't exist - you may want to collect these and return as warnings
continue;
}
// Map ShareRole to AssetPermissionRole
const roleMapping = {
owner: 'owner',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
viewer: 'can_view', // Map viewer to can_view
} as const;
const mappedRole = roleMapping[userPermission.role];
if (!mappedRole) {
throw new HTTPException(400, {
message: `Invalid role: ${userPermission.role} for user ${userPermission.email}`,
});
}
permissions.push({
identityId: targetUser.id,
identityType: 'user' as const,
assetId: dashboardId,
assetType: 'dashboard_file' as const,
role: mappedRole,
createdBy: user.id,
});
}
// Create/update permissions in bulk
if (permissions.length > 0) {
await bulkCreateAssetPermissions({ permissions });
}
}
// Update dashboard sharing settings
await updateDashboard({
dashboardId,
userId: user.id,
publicly_accessible,
public_expiry_date,
public_password,
workspace_sharing,
});
const updatedDashboard: GetDashboardResponse = await getDashboardHandler({ dashboardId }, user);
return updatedDashboard;
}
const app = new Hono().put('/', zValidator('json', ShareUpdateRequestSchema), async (c) => {
const dashboardId = c.req.param('id');
const request = c.req.valid('json');
const user = c.get('busterUser');
if (!dashboardId) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User is not associated with an organization' });
}
const updatedDashboard: GetDashboardResponse = await updateDashboardShareHandler(
dashboardId,
request,
{
...user,
organizationId: userOrg.organizationId,
}
);
return c.json(updatedDashboard);
});
export default app;

View File

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

View File

@ -3,11 +3,13 @@ import { standardErrorHandler } from '../../../../utils/response';
import GET from './GET';
import DATA from './data/GET';
import DOWNLOAD from './download/GET';
import SHARING from './sharing';
const app = new Hono()
.route('/', GET)
.route('/data', DATA)
.route('/download', DOWNLOAD)
.route('/sharing', SHARING)
.onError(standardErrorHandler);
export default app;

View File

@ -0,0 +1,93 @@
import { checkPermission } from '@buster/access-controls';
import {
findUsersByEmails,
getMetricFileById,
removeAssetPermission,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareDeleteResponse } from '@buster/server-shared/share';
import type { ShareDeleteRequest } from '@buster/server-shared/share';
import { ShareDeleteRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function deleteMetricSharingHandler(
metricId: string,
emails: ShareDeleteRequest,
user: User
): Promise<ShareDeleteResponse> {
// Get the metric to verify it exists and get owner info
const metric = await getMetricFileById(metricId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: metricId,
assetType: 'metric_file',
requiredRole: 'full_access',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to delete sharing for this metric',
});
}
// Find users by emails
const userMap = await findUsersByEmails(emails);
const removedEmails = [];
const notFoundEmails = [];
for (const email of emails) {
const targetUser = userMap.get(email);
if (!targetUser) {
notFoundEmails.push(email);
continue;
}
// Don't allow removing permissions from the owner
if (targetUser.id === metric.createdBy) {
continue; // Skip the owner
}
// Remove the permission
await removeAssetPermission({
identityId: targetUser.id,
identityType: 'user',
assetId: metricId,
assetType: 'metric_file',
updatedBy: user.id,
});
removedEmails.push(email);
}
return {
success: true,
removed: removedEmails,
notFound: notFoundEmails,
};
}
const app = new Hono().delete('/', zValidator('json', ShareDeleteRequestSchema), async (c) => {
const metricId = c.req.param('id');
const emails = c.req.valid('json');
const user = c.get('busterUser');
if (!metricId) {
throw new HTTPException(400, { message: 'Metric ID is required' });
}
const result = await deleteMetricSharingHandler(metricId, emails, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,58 @@
import { checkPermission } from '@buster/access-controls';
import { getMetricFileById, listAssetPermissions } from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareGetResponse } from '@buster/server-shared/reports';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
export async function getMetricSharingHandler(
metricId: string,
user: User
): Promise<ShareGetResponse> {
// Check if metric exists
const metric = await getMetricFileById(metricId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
// Check if user has permission to view the metric
const permissionCheck = await checkPermission({
userId: user.id,
assetId: metricId,
assetType: 'metric_file',
requiredRole: 'can_view',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to view this metric',
});
}
// Get all permissions for the metric
const permissions = await listAssetPermissions({
assetId: metricId,
assetType: 'metric_file',
});
return {
permissions,
};
}
const app = new Hono().get('/', async (c) => {
const metricId = c.req.param('id');
const user = c.get('busterUser');
if (!metricId) {
throw new HTTPException(400, { message: 'Metric ID is required' });
}
const result = await getMetricSharingHandler(metricId, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,116 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getMetricFileById,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { SharePostResponse } from '@buster/server-shared/share';
import type { SharePostRequest } from '@buster/server-shared/share';
import { SharePostRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { checkIfAssetIsEditable } from '../../../../../shared-helpers/asset-public-access';
export async function createMetricSharingHandler(
metricId: string,
shareRequests: SharePostRequest,
user: User
): Promise<SharePostResponse> {
// Get the metric to verify it exists
const metric = await getMetricFileById(metricId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
// Check if user has permission to edit the metric
const permissionCheck = await checkPermission({
userId: user.id,
assetId: metricId,
assetType: 'metric_file',
requiredRole: 'can_edit',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to edit this metric',
});
}
// Extract emails from the share requests
const emails = shareRequests.map((req) => req.email);
// Find users by emails
const userMap = await findUsersByEmails(emails);
const permissions = [];
const sharedEmails = [];
const notFoundEmails = [];
for (const shareRequest of shareRequests) {
const targetUser = userMap.get(shareRequest.email);
if (!targetUser) {
notFoundEmails.push(shareRequest.email);
continue;
}
sharedEmails.push(shareRequest.email);
// Map ShareRole to AssetPermissionRole
const roleMapping = {
owner: 'owner',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
viewer: 'can_view', // Map viewer to can_view
} as const;
const mappedRole = roleMapping[shareRequest.role];
if (!mappedRole) {
throw new HTTPException(400, {
message: `Invalid role: ${shareRequest.role} for user ${shareRequest.email}`,
});
}
permissions.push({
identityId: targetUser.id,
identityType: 'user' as const,
assetId: metricId,
assetType: 'metric_file' as const,
role: mappedRole,
createdBy: user.id,
});
}
// Create permissions in bulk
if (permissions.length > 0) {
await bulkCreateAssetPermissions({ permissions });
}
return {
success: true,
shared: sharedEmails,
notFound: notFoundEmails,
};
}
const app = new Hono().post('/', zValidator('json', SharePostRequestSchema), async (c) => {
const metricId = c.req.param('id');
const shareRequests = c.req.valid('json');
const user = c.get('busterUser');
if (!metricId) {
throw new HTTPException(400, { message: 'Metric ID is required' });
}
const result = await createMetricSharingHandler(metricId, shareRequests, user);
return c.json(result);
});
export default app;

View File

@ -0,0 +1,139 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getMetricFileById,
getUserOrganizationId,
updateMetric,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareMetricUpdateResponse } from '@buster/server-shared/metrics';
import { type ShareUpdateRequest, ShareUpdateRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { getMetricHandler } from '../GET';
export async function updateMetricShareHandler(
metricId: string,
request: ShareUpdateRequest,
user: User & { organizationId: string }
) {
// Check if metric exists
const metric = await getMetricFileById(metricId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: metricId,
assetType: 'metric_file',
requiredRole: 'full_access',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to update sharing for this metric',
});
}
const { publicly_accessible, public_expiry_date, public_password, workspace_sharing, users } =
request;
// Handle user permissions if provided
if (users && users.length > 0) {
// Extract emails from the user permissions
const emails = users.map((u) => u.email);
// Find users by emails
const userMap = await findUsersByEmails(emails);
const permissions = [];
for (const userPermission of users) {
const targetUser = userMap.get(userPermission.email);
if (!targetUser) {
// Skip users that don't exist - you may want to collect these and return as warnings
continue;
}
// Map ShareRole to AssetPermissionRole
const roleMapping = {
owner: 'owner',
full_access: 'full_access',
can_edit: 'can_edit',
can_filter: 'can_filter',
can_view: 'can_view',
viewer: 'can_view', // Map viewer to can_view
} as const;
const mappedRole = roleMapping[userPermission.role];
if (!mappedRole) {
throw new HTTPException(400, {
message: `Invalid role: ${userPermission.role} for user ${userPermission.email}`,
});
}
permissions.push({
identityId: targetUser.id,
identityType: 'user' as const,
assetId: metricId,
assetType: 'metric_file' as const,
role: mappedRole,
createdBy: user.id,
});
}
// Create/update permissions in bulk
if (permissions.length > 0) {
await bulkCreateAssetPermissions({ permissions });
}
}
// Update metric sharing settings
await updateMetric({
metricId,
userId: user.id,
publicly_accessible,
public_expiry_date,
public_password,
workspace_sharing,
});
const updatedMetric: ShareMetricUpdateResponse = await getMetricHandler({ metricId }, user);
return updatedMetric;
}
const app = new Hono().put('/', zValidator('json', ShareUpdateRequestSchema), async (c) => {
const metricId = c.req.param('id');
const request = c.req.valid('json');
const user = c.get('busterUser');
if (!metricId) {
throw new HTTPException(404, { message: 'Metric not found' });
}
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(403, { message: 'User is not associated with an organization' });
}
const updatedMetric: ShareMetricUpdateResponse = await updateMetricShareHandler(
metricId,
request,
{
...user,
organizationId: userOrg.organizationId,
}
);
return c.json(updatedMetric);
});
export default app;

View File

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

View File

@ -1,37 +1,43 @@
import { checkPermission } from '@buster/access-controls';
import {
findUsersByEmails,
getReportFileById,
getReportWorkspaceSharing,
removeAssetPermission,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { ShareDeleteResponse } from '@buster/server-shared/reports';
import type { ShareDeleteResponse } from '@buster/server-shared/share';
import type { ShareDeleteRequest } from '@buster/server-shared/share';
import { ShareDeleteRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { checkIfAssetIsEditable } from '../../../../../shared-helpers/asset-public-access';
export async function deleteReportSharingHandler(
reportId: string,
emails: ShareDeleteRequest,
user: User
): Promise<ShareDeleteResponse> {
await checkIfAssetIsEditable({
user,
assetId: reportId,
assetType: 'report_file',
workspaceSharing: getReportWorkspaceSharing,
requiredRole: 'full_access',
});
// Get the report to verify it exists and get owner info
const report = await getReportFileById({ reportId, userId: user.id });
if (!report) {
throw new HTTPException(404, { message: 'Report not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: reportId,
assetType: 'report_file',
requiredRole: 'full_access',
workspaceSharing: report.workspace_sharing,
organizationId: report.organization_id,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to delete sharing for this report',
});
}
// Find users by emails
const userMap = await findUsersByEmails(emails);

View File

@ -1,3 +1,4 @@
import { checkPermission } from '@buster/access-controls';
import {
checkAssetPermission,
getReportFileById,
@ -19,10 +20,13 @@ export async function getReportSharingHandler(
}
// Check if user has permission to view the report
const permissionCheck = await checkAssetPermission({
const permissionCheck = await checkPermission({
userId: user.id,
assetId: reportId,
assetType: 'report_file',
userId: user.id,
requiredRole: 'can_view',
workspaceSharing: report.workspace_sharing,
organizationId: report.organization_id,
});
if (!permissionCheck.hasAccess) {

View File

@ -1,37 +1,44 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
findUsersByEmails,
getReportFileById,
getReportWorkspaceSharing,
} from '@buster/database/queries';
import type { User } from '@buster/database/queries';
import type { SharePostResponse } from '@buster/server-shared/reports';
import type { SharePostResponse } from '@buster/server-shared/share';
import type { SharePostRequest } from '@buster/server-shared/share';
import { SharePostRequestSchema } from '@buster/server-shared/share';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { checkIfAssetIsEditable } from '../../../../../shared-helpers/asset-public-access';
export async function createReportSharingHandler(
reportId: string,
shareRequests: SharePostRequest,
user: User
): Promise<SharePostResponse> {
await checkIfAssetIsEditable({
user,
assetId: reportId,
assetType: 'report_file',
workspaceSharing: getReportWorkspaceSharing,
requiredRole: 'can_edit',
});
// Get the report to verify it exists
const report = await getReportFileById({ reportId, userId: user.id });
if (!report) {
throw new HTTPException(404, { message: 'Report not found' });
}
// Check if user has permission to edit the report
const permissionCheck = await checkPermission({
userId: user.id,
assetId: reportId,
assetType: 'report_file',
requiredRole: 'can_edit',
workspaceSharing: report.workspace_sharing,
organizationId: report.organization_id,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to edit this report',
});
}
// Extract emails from the share requests
const emails = shareRequests.map((req) => req.email);

View File

@ -1,9 +1,8 @@
import { checkPermission } from '@buster/access-controls';
import {
bulkCreateAssetPermissions,
checkAssetPermission,
findUsersByEmails,
getReportFileById,
getReportWorkspaceSharing,
getUserOrganizationId,
updateReport,
} from '@buster/database/queries';
@ -13,7 +12,6 @@ import { type ShareUpdateRequest, ShareUpdateRequestSchema } from '@buster/serve
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { checkIfAssetIsEditable } from '../../../../../shared-helpers/asset-public-access';
import { getReportHandler } from '../GET';
export async function updateReportShareHandler(
@ -21,29 +19,27 @@ export async function updateReportShareHandler(
request: ShareUpdateRequest,
user: User & { organizationId: string }
) {
// Check if user has permission to edit asset permissions
const permissionCheck = await checkAssetPermission({
assetId: reportId,
assetType: 'report_file',
userId: user.id,
});
// Check if user has at least full_access permission
if (
!permissionCheck.hasAccess ||
(permissionCheck.role !== 'full_access' && permissionCheck.role !== 'owner')
) {
throw new HTTPException(403, {
message: 'User does not have permission to edit asset permissions',
});
}
// Check if report exists
const report = await getReportFileById({ reportId, userId: user.id });
if (!report) {
throw new HTTPException(404, { message: 'Report not found' });
}
const permissionCheck = await checkPermission({
userId: user.id,
assetId: reportId,
assetType: 'report_file',
requiredRole: 'full_access',
workspaceSharing: report.workspace_sharing,
organizationId: report.organization_id,
});
if (!permissionCheck.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to update sharing for this report',
});
}
const { publicly_accessible, public_expiry_date, public_password, workspace_sharing, users } =
request;
@ -131,15 +127,6 @@ const app = new Hono().put('/', zValidator('json', ShareUpdateRequestSchema), as
throw new HTTPException(403, { message: 'User is not associated with an organization' });
}
await checkIfAssetIsEditable({
user,
assetId: reportId,
assetType: 'report_file',
workspaceSharing: getReportWorkspaceSharing,
organizationId: userOrg.organizationId,
requiredRole: 'full_access',
});
const updatedReport: ShareUpdateResponse = await updateReportShareHandler(reportId, request, {
...user,
organizationId: userOrg.organizationId,

View File

@ -14,7 +14,9 @@ import type {
} from '@buster/server-shared/chats';
import type {
ShareDeleteRequest,
ShareDeleteResponse,
SharePostRequest,
SharePostResponse,
ShareUpdateRequest,
} from '@buster/server-shared/share';
import { mainApi, mainApiV2 } from '../instances';
@ -61,11 +63,15 @@ export const duplicateChat = async ({
};
export const shareChat = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApi.post<string>(`${CHATS_BASE}/${id}/sharing`, params).then((res) => res.data);
return mainApiV2
.post<SharePostResponse>(`${CHATS_BASE}/${id}/sharing`, params)
.then((res) => res.data);
};
export const unshareChat = async ({ id, data }: { id: string; data: ShareDeleteRequest }) => {
return mainApi.delete<boolean>(`${CHATS_BASE}/${id}/sharing`, { data }).then((res) => res.data);
return mainApiV2
.delete<ShareDeleteResponse>(`${CHATS_BASE}/${id}/sharing`, { data })
.then((res) => res.data);
};
export const updateChatShare = async ({
@ -75,7 +81,7 @@ export const updateChatShare = async ({
id: string;
params: ShareUpdateRequest;
}) => {
return mainApi
return mainApiV2
.put<ShareChatResponse>(`${CHATS_BASE}/${id}/sharing`, params)
.then((res) => res.data);
};

View File

@ -1,7 +1,9 @@
import type { DashboardConfig, GetDashboardResponse } from '@buster/server-shared/dashboards';
import type {
ShareDeleteRequest,
ShareDeleteResponse,
SharePostRequest,
SharePostResponse,
ShareUpdateRequest,
} from '@buster/server-shared/share';
import type { BusterDashboardListItem } from '@/api/asset_interfaces/dashboard';
@ -76,11 +78,15 @@ export const dashboardsDeleteDashboard = async (data: { ids: string[] }) => {
// share dashboards
export const shareDashboard = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApi.post<string>(`/dashboards/${id}/sharing`, params).then((res) => res.data);
return mainApiV2
.post<SharePostResponse>(`/dashboards/${id}/sharing`, params)
.then((res) => res.data);
};
export const unshareDashboard = async ({ id, data }: { id: string; data: ShareDeleteRequest }) => {
return mainApi.delete<string>(`/dashboards/${id}/sharing`, { data }).then((res) => res.data);
return mainApiV2
.delete<ShareDeleteResponse>(`/dashboards/${id}/sharing`, { data })
.then((res) => res.data);
};
export const updateDashboardShare = async ({
@ -90,7 +96,7 @@ export const updateDashboardShare = async ({
id: string;
params: ShareUpdateRequest;
}) => {
return mainApi
return mainApiV2
.put<GetDashboardResponse>(`/dashboards/${id}/sharing`, params)
.then((res) => res.data);
};

View File

@ -15,14 +15,15 @@ import type {
MetricDownloadParams,
MetricDownloadQueryParams,
MetricDownloadResponse,
ShareDeleteResponse,
ShareUpdateResponse,
ShareMetricUpdateResponse,
UpdateMetricRequest,
UpdateMetricResponse,
} from '@buster/server-shared/metrics';
import type {
ShareDeleteRequest,
ShareDeleteResponse,
SharePostRequest,
SharePostResponse,
ShareUpdateRequest,
} from '@buster/server-shared/share';
import { mainApi, mainApiV2 } from '../instances';
@ -80,11 +81,13 @@ export const bulkUpdateMetricVerificationStatus = async (
// share metrics
export const shareMetric = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApi.post<string>(`/metric_files/${id}/sharing`, params).then((res) => res.data);
return mainApiV2
.post<SharePostResponse>(`/metric_files/${id}/sharing`, params)
.then((res) => res.data);
};
export const unshareMetric = async ({ id, data }: { id: string; data: ShareDeleteRequest }) => {
return mainApi
return mainApiV2
.delete<ShareDeleteResponse>(`/metric_files/${id}/sharing`, { data })
.then((res) => res.data);
};
@ -96,8 +99,8 @@ export const updateMetricShare = async ({
id: string;
params: ShareUpdateRequest;
}) => {
return mainApi
.put<ShareUpdateResponse>(`/metric_files/${id}/sharing`, params)
return mainApiV2
.put<ShareMetricUpdateResponse>(`/metric_files/${id}/sharing`, params)
.then((res) => res.data);
};

View File

@ -282,13 +282,6 @@ export const useUnshareMetric = () => {
});
});
},
onSuccess: (data) => {
const upgradedMetric = upgradeMetricToIMetric(data, null);
queryClient.setQueryData(
metricsQueryKeys.metricsGetMetric(data.id, 'LATEST').queryKey,
upgradedMetric
);
},
});
};

View File

@ -7,7 +7,12 @@ import type {
UpdateReportRequest,
UpdateReportResponse,
} from '@buster/server-shared/reports';
import type { SharePostRequest, ShareUpdateRequest } from '@buster/server-shared/share';
import type {
ShareDeleteResponse,
SharePostRequest,
SharePostResponse,
ShareUpdateRequest,
} from '@buster/server-shared/share';
import { mainApiV2 } from '../instances';
/**
@ -42,7 +47,7 @@ export const updateReport = async ({
*/
export const shareReport = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApiV2
.post<GetReportResponse>(`/reports/${id}/sharing`, params)
.post<SharePostResponse>(`/reports/${id}/sharing`, params)
.then((res) => res.data);
};
@ -51,7 +56,7 @@ export const shareReport = async ({ id, params }: { id: string; params: SharePos
*/
export const unshareReport = async ({ id, data }: { id: string; data: string[] }) => {
return mainApiV2
.delete<GetReportResponse>(`/reports/${id}/sharing`, { data })
.delete<ShareDeleteResponse>(`/reports/${id}/sharing`, { data })
.then((res) => res.data);
};

View File

@ -29,6 +29,9 @@ export function createDoneToolDelta(context: DoneToolContext, doneToolState: Don
return async function doneToolDelta(
options: { inputTextDelta: string } & ToolCallOptions
): Promise<void> {
if (doneToolState.isFinalizing) {
return;
}
// Accumulate the delta to the args
doneToolState.args = (doneToolState.args || '') + options.inputTextDelta;
@ -52,32 +55,71 @@ export function createDoneToolDelta(context: DoneToolContext, doneToolState: Don
assetId: string;
assetName: string;
assetType: ResponseMessageFileType;
versionNumber: number;
};
function isAssetToReturn(value: unknown): value is AssetToReturn {
if (!value || typeof value !== 'object') return false;
const obj = value as Record<string, unknown>;
const idOk = typeof obj.assetId === 'string';
const nameOk = typeof obj.assetName === 'string';
const typeVal = obj.assetType;
const typeOk =
typeof typeVal === 'string' &&
ResponseMessageFileTypeSchema.options.includes(typeVal as ResponseMessageFileType);
return idOk && nameOk && typeOk;
}
let assetsToInsert: AssetToReturn[] = [];
if (Array.isArray(rawAssets)) {
assetsToInsert = rawAssets.filter(isAssetToReturn);
} else if (typeof rawAssets === 'string') {
try {
const parsed: unknown = JSON.parse(rawAssets);
if (Array.isArray(parsed)) {
assetsToInsert = parsed.filter(isAssetToReturn);
}
} catch {
// ignore malformed JSON until more delta arrives
const rawAssetItems: unknown[] = (() => {
if (Array.isArray(rawAssets)) {
return rawAssets;
}
if (typeof rawAssets === 'string') {
try {
const parsed: unknown = JSON.parse(rawAssets);
if (Array.isArray(parsed)) {
return parsed;
}
} catch {
// ignore malformed JSON until more delta arrives
}
}
return [];
})();
const assetsToInsert: AssetToReturn[] = [];
for (const candidate of rawAssetItems) {
if (!candidate || typeof candidate !== 'object') {
continue;
}
const data = candidate as Record<string, unknown>;
const assetId = typeof data.assetId === 'string' ? data.assetId : undefined;
const assetName = typeof data.assetName === 'string' ? data.assetName : undefined;
const rawType = data.assetType;
const normalizedType =
typeof rawType === 'string' &&
ResponseMessageFileTypeSchema.options.includes(rawType as ResponseMessageFileType)
? (rawType as ResponseMessageFileType)
: undefined;
if (!assetId || !assetName || !normalizedType) {
continue;
}
let versionNumber: number | undefined;
if (typeof data.versionNumber === 'number') {
versionNumber = data.versionNumber;
} else if (typeof (data as { version_number?: unknown }).version_number === 'number') {
versionNumber = (data as { version_number: number }).version_number;
}
if (versionNumber === undefined || Number.isNaN(versionNumber) || versionNumber <= 0) {
try {
versionNumber = await getAssetLatestVersion({
assetId,
assetType: normalizedType,
});
} catch (error) {
console.error('[done-tool] Failed to fetch asset version, defaulting to 1:', error);
versionNumber = 1;
}
}
assetsToInsert.push({
assetId,
assetName,
assetType: normalizedType,
versionNumber,
});
}
// Insert any newly completed asset items as response messages (dedupe via state)
@ -95,7 +137,7 @@ export function createDoneToolDelta(context: DoneToolContext, doneToolState: Don
type: 'file' as const,
file_type: a.assetType,
file_name: a.assetName,
version_number: 1,
version_number: a.versionNumber,
filter_version_id: null,
metadata: [
{
@ -128,7 +170,11 @@ export function createDoneToolDelta(context: DoneToolContext, doneToolState: Don
if (newAssets.length > 0) {
doneToolState.addedAssets = [
...(doneToolState.addedAssets || []),
...newAssets.map((a) => ({ assetId: a.assetId, assetType: a.assetType })),
...newAssets.map((a) => ({
assetId: a.assetId,
assetType: a.assetType,
versionNumber: a.versionNumber,
})),
];
}
}
@ -159,16 +205,10 @@ export function createDoneToolDelta(context: DoneToolContext, doneToolState: Don
const firstAsset = doneToolState.addedAssets[0];
if (firstAsset) {
// Get the actual version number from the database
const versionNumber = await getAssetLatestVersion({
assetId: firstAsset.assetId,
assetType: firstAsset.assetType,
});
await updateChat(context.chatId, {
mostRecentFileId: firstAsset.assetId,
mostRecentFileType: firstAsset.assetType,
mostRecentVersionNumber: versionNumber,
mostRecentVersionNumber: firstAsset.versionNumber,
});
}
} catch (error) {

View File

@ -75,6 +75,7 @@ export function createDoneToolExecute(context: DoneToolContext, state: DoneToolS
throw new Error('Tool call ID is required');
}
state.isFinalizing = true;
// CRITICAL: Wait for ALL pending updates from delta/finish to complete FIRST
// This ensures execute's update is always the last one in the queue
await waitForPendingUpdates(context.messageId);

View File

@ -24,6 +24,7 @@ export function createDoneToolStart(context: DoneToolContext, doneToolState: Don
doneToolState.finalResponse = undefined;
doneToolState.addedAssetIds = [];
doneToolState.addedAssets = [];
doneToolState.isFinalizing = false;
// Selection logic moved to delta; skip extracting files here
if (options.messages) {

View File

@ -603,6 +603,29 @@ describe('Done Tool Streaming Tests', () => {
expect(state.args).toBe('{"finalResponse": ""}');
expect(state.finalResponse).toBeUndefined();
});
test('should ignore deltas after execute begins', async () => {
vi.clearAllMocks();
const state: DoneToolState = {
toolCallId: 'test-entry',
args: '',
finalResponse: 'Complete response',
isFinalizing: true,
};
const deltaHandler = createDoneToolDelta(mockContext, state);
await deltaHandler({
inputTextDelta: '{"finalResponse": "Stale"}',
toolCallId: 'tool-call-123',
messages: [],
});
const queries = await import('@buster/database/queries');
expect(queries.updateMessageEntries).not.toHaveBeenCalled();
expect(state.args).toBe('');
expect(state.finalResponse).toBe('Complete response');
});
});
describe('createDoneToolFinish', () => {

View File

@ -15,6 +15,11 @@ export const DoneToolInputSchema = z.object({
assetId: z.string().uuid(),
assetName: z.string(),
assetType: AssetTypeSchema,
versionNumber: z
.number()
.int()
.positive()
.describe('The version number of the asset to return'),
})
)
.describe(
@ -60,17 +65,16 @@ const DoneToolStateSchema = z.object({
.array(
z.object({
assetId: z.string(),
assetType: z.enum([
'metric_file',
'dashboard_file',
'report_file',
'analyst_chat',
'collection',
]),
assetType: AssetTypeSchema,
versionNumber: z.number(),
})
)
.optional()
.describe('Assets that have been added with their types for chat update'),
.describe('Assets that have been added with their types and version numbers for chat update'),
isFinalizing: z
.boolean()
.optional()
.describe('Indicates the execute phase has started so further deltas should be ignored'),
});
export type DoneToolInput = z.infer<typeof DoneToolInputSchema>;
@ -85,6 +89,7 @@ export function createDoneTool(context: DoneToolContext) {
finalResponse: undefined,
addedAssetIds: [],
addedAssets: [],
isFinalizing: false,
};
const execute = createDoneToolExecute(context, state);

View File

@ -2,8 +2,8 @@ import type { LanguageModelV2ToolCall } from '@ai-sdk/provider';
import { type ModelMessage, NoSuchToolError, generateText, streamText } from 'ai';
import { wrapTraced } from 'braintrust';
import { ANALYST_AGENT_NAME, DOCS_AGENT_NAME, THINK_AND_PREP_AGENT_NAME } from '../../../agents';
import { Sonnet4 } from '../../../llm';
import { DEFAULT_ANTHROPIC_OPTIONS } from '../../../llm/providers/gateway';
import { GPT5Mini, Sonnet4 } from '../../../llm';
import { DEFAULT_ANTHROPIC_OPTIONS, DEFAULT_OPENAI_OPTIONS } from '../../../llm/providers/gateway';
import type { RepairContext } from '../types';
export function canHandleNoSuchTool(error: Error): boolean {
@ -58,11 +58,11 @@ export async function repairWrongToolName(
try {
const result = streamText({
model: Sonnet4,
providerOptions: DEFAULT_ANTHROPIC_OPTIONS,
model: GPT5Mini,
providerOptions: DEFAULT_OPENAI_OPTIONS,
messages: healingMessages,
tools: context.tools,
maxOutputTokens: 1000,
maxOutputTokens: 10000,
temperature: 0,
});

View File

@ -1,8 +1,8 @@
import type { LanguageModelV2ToolCall } from '@ai-sdk/provider';
import { InvalidToolInputError, generateObject } from 'ai';
import { wrapTraced } from 'braintrust';
import { Sonnet4 } from '../../../llm';
import { DEFAULT_ANTHROPIC_OPTIONS } from '../../../llm/providers/gateway';
import { GPT5Mini, Sonnet4 } from '../../../llm';
import { DEFAULT_ANTHROPIC_OPTIONS, DEFAULT_OPENAI_OPTIONS } from '../../../llm/providers/gateway';
import type { RepairContext } from '../types';
export function canHandleInvalidInput(error: Error): boolean {
@ -41,9 +41,10 @@ export async function repairInvalidInput(
try {
const { object: repairedInput } = await generateObject({
model: Sonnet4,
providerOptions: DEFAULT_ANTHROPIC_OPTIONS,
model: GPT5Mini,
providerOptions: DEFAULT_OPENAI_OPTIONS,
schema: tool.inputSchema,
maxOutputTokens: 10000,
prompt: `Fix these tool arguments to match the schema:\n${JSON.stringify(currentInput, null, 2)}`,
mode: 'json',
});

View File

@ -0,0 +1,148 @@
-- Create Buster system user if it doesn't exist
DO $$
DECLARE
buster_user_id uuid;
BEGIN
-- Check if Buster user already exists
SELECT id INTO buster_user_id
FROM users
WHERE email = 'support@buster.so';
-- If not exists, create the user
IF buster_user_id IS NULL THEN
INSERT INTO users (
email,
name,
created_at,
updated_at
) VALUES (
'support@buster.so',
'Buster',
now(),
now()
)
RETURNING id INTO buster_user_id;
END IF;
END $$;
--> statement-breakpoint
-- Function to add default shortcuts for an organization
CREATE OR REPLACE FUNCTION add_default_shortcuts_for_org(org_id uuid)
RETURNS void AS $$
DECLARE
buster_user_id uuid;
BEGIN
-- Get the Buster user ID
SELECT id INTO buster_user_id
FROM users
WHERE email = 'support@buster.so';
-- Insert default shortcuts if they don't exist
-- quick shortcut
INSERT INTO shortcuts (name, instructions, created_by, organization_id, share_with_workspace, created_at, updated_at)
VALUES (
'quick',
'Quickly answer my request as fast as possible. Use as little prep, thoughts, validation as possible. Again, this should be fulfilled as quickly as possible: ',
buster_user_id,
org_id,
true,
now(),
now()
)
ON CONFLICT (name, organization_id) WHERE share_with_workspace = true
DO NOTHING;
-- deep-dive shortcut
INSERT INTO shortcuts (name, instructions, created_by, organization_id, share_with_workspace, created_at, updated_at)
VALUES (
'deep-dive',
'Do a deep dive and build me a thorough report. Find meaningful insights and thoroughly explore. If I have a specific topic in mind, I''ll include it here: ',
buster_user_id,
org_id,
true,
now(),
now()
)
ON CONFLICT (name, organization_id) WHERE share_with_workspace = true
DO NOTHING;
-- csv shortcut
INSERT INTO shortcuts (name, instructions, created_by, organization_id, share_with_workspace, created_at, updated_at)
VALUES (
'csv',
'Return a table/list that I can export as a CSV. Here is my request: ',
buster_user_id,
org_id,
true,
now(),
now()
)
ON CONFLICT (name, organization_id) WHERE share_with_workspace = true
DO NOTHING;
-- suggestions shortcut
INSERT INTO shortcuts (name, instructions, created_by, organization_id, share_with_workspace, created_at, updated_at)
VALUES (
'suggestions',
'What specific questions I can ask that may be of value? The more specific and high-impact the better. If I have a specific topic in mind, I''ll include it here: ',
buster_user_id,
org_id,
true,
now(),
now()
)
ON CONFLICT (name, organization_id) WHERE share_with_workspace = true
DO NOTHING;
-- dashboard shortcut
INSERT INTO shortcuts (name, instructions, created_by, organization_id, share_with_workspace, created_at, updated_at)
VALUES (
'dashboard',
'Build me a dashboard. If I have a specific topic in mind, I''ll include it here: ',
buster_user_id,
org_id,
true,
now(),
now()
)
ON CONFLICT (name, organization_id) WHERE share_with_workspace = true
DO NOTHING;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
-- Backfill existing organizations with default shortcuts
DO $$
DECLARE
org_record RECORD;
BEGIN
FOR org_record IN
SELECT id
FROM organizations
WHERE deleted_at IS NULL
LOOP
PERFORM add_default_shortcuts_for_org(org_record.id);
END LOOP;
END $$;
--> statement-breakpoint
-- Create trigger function for new organizations
CREATE OR REPLACE FUNCTION add_default_shortcuts_on_org_create()
RETURNS TRIGGER AS $$
BEGIN
PERFORM add_default_shortcuts_for_org(NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
-- Create trigger on organization insert
DROP TRIGGER IF EXISTS add_default_shortcuts_trigger ON organizations;
CREATE TRIGGER add_default_shortcuts_trigger
AFTER INSERT ON organizations
FOR EACH ROW
EXECUTE FUNCTION add_default_shortcuts_on_org_create();

File diff suppressed because it is too large Load Diff

View File

@ -782,8 +782,8 @@
{
"idx": 112,
"version": "7",
"when": 1759181434094,
"tag": "0112_write-back-logs-config",
"when": 1759179073594,
"tag": "0112_shortcuts_trigger_backfill",
"breakpoints": true
}
]

View File

@ -223,3 +223,56 @@ export async function updateChat(
throw new Error(`Failed to update chat fields for chat ${chatId}`);
}
}
/**
* Updates a chat's sharing settings
*/
export async function updateChatSharing(
chatId: string,
userId: string,
options: {
publicly_accessible?: boolean;
public_expiry_date?: string | null;
workspace_sharing?: 'none' | 'can_view' | 'can_edit' | 'full_access';
}
): Promise<{ success: boolean }> {
const updateFields: UpdateableChatFields = {
updatedBy: userId,
};
if (options.publicly_accessible !== undefined) {
updateFields.publiclyAccessible = options.publicly_accessible;
updateFields.publiclyEnabledBy = options.publicly_accessible ? userId : null;
}
if (options.public_expiry_date !== undefined) {
updateFields.publicExpiryDate = options.public_expiry_date;
}
if (options.workspace_sharing !== undefined) {
updateFields.workspaceSharing = options.workspace_sharing;
if (options.workspace_sharing !== 'none') {
updateFields.workspaceSharingEnabledBy = userId;
updateFields.workspaceSharingEnabledAt = new Date().toISOString();
} else {
updateFields.workspaceSharingEnabledBy = null;
updateFields.workspaceSharingEnabledAt = null;
}
}
return await updateChat(chatId, updateFields);
}
/**
* Get a chat by ID (simple version for sharing handlers)
*/
export async function getChatById(chatId: string): Promise<Chat | null> {
const [chat] = await db
.select()
.from(chats)
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt)))
.limit(1);
return chat || null;
}

View File

@ -4,6 +4,8 @@ export {
updateChat,
getChatWithDetails,
createMessage,
updateChatSharing,
getChatById,
CreateChatInputSchema,
GetChatInputSchema,
CreateMessageInputSchema,

View File

@ -16,6 +16,8 @@ export {
type GetDashboardByIdInput,
} from './get-dashboard-by-id';
export { updateDashboard } from './update-dashboard';
export {
getCollectionsAssociatedWithDashboard,
type AssociatedCollection,

View File

@ -0,0 +1,92 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { dashboardFiles } from '../../schema';
import { WorkspaceSharingSchema } from '../../schema-types';
// Type for updating dashboardFiles - excludes read-only fields
type UpdateDashboardData = Partial<
Omit<typeof dashboardFiles.$inferInsert, 'id' | 'createdBy' | 'createdAt' | 'deletedAt'>
>;
// Input validation schema for updating a dashboard
const UpdateDashboardInputSchema = z.object({
dashboardId: z.string().uuid('Dashboard 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(),
public_expiry_date: z.string().nullable().optional(),
public_password: z.string().nullable().optional(),
workspace_sharing: WorkspaceSharingSchema.optional(),
});
type UpdateDashboardInput = z.infer<typeof UpdateDashboardInputSchema>;
/**
* Updates a dashboard with the provided fields
* Only updates fields that are provided in the input
* Always updates the updatedAt timestamp
*/
export const updateDashboard = async (params: UpdateDashboardInput): Promise<void> => {
// Validate and destructure input
const {
dashboardId,
userId,
name,
publicly_accessible,
public_expiry_date,
public_password,
workspace_sharing,
} = UpdateDashboardInputSchema.parse(params);
try {
// Build update data - only include fields that are provided
const updateData: UpdateDashboardData = {
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 (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 the dashboard
await db
.update(dashboardFiles)
.set(updateData)
.where(and(eq(dashboardFiles.id, dashboardId), isNull(dashboardFiles.deletedAt)));
console.info(`Successfully updated dashboard ${dashboardId}`);
} catch (error) {
console.error(`Failed to update dashboard ${dashboardId}:`, error);
throw new Error(
`Failed to update dashboard: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};

View File

@ -32,6 +32,8 @@ export {
type MetricFile,
} from './get-metric-by-id';
export { updateMetric } from './update-metric';
export {
getDashboardsAssociatedWithMetric,
getCollectionsAssociatedWithMetric,

View File

@ -0,0 +1,92 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { metricFiles } from '../../schema';
import { WorkspaceSharingSchema } from '../../schema-types';
// Type for updating metricFiles - excludes read-only fields
type UpdateMetricData = Partial<
Omit<typeof metricFiles.$inferInsert, 'id' | 'createdBy' | 'createdAt' | 'deletedAt'>
>;
// Input validation schema for updating a metric
const UpdateMetricInputSchema = z.object({
metricId: z.string().uuid('Metric 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(),
public_expiry_date: z.string().nullable().optional(),
public_password: z.string().nullable().optional(),
workspace_sharing: WorkspaceSharingSchema.optional(),
});
type UpdateMetricInput = z.infer<typeof UpdateMetricInputSchema>;
/**
* Updates a metric with the provided fields
* Only updates fields that are provided in the input
* Always updates the updatedAt timestamp
*/
export const updateMetric = async (params: UpdateMetricInput): Promise<void> => {
// Validate and destructure input
const {
metricId,
userId,
name,
publicly_accessible,
public_expiry_date,
public_password,
workspace_sharing,
} = UpdateMetricInputSchema.parse(params);
try {
// Build update data - only include fields that are provided
const updateData: UpdateMetricData = {
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 (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 the metric
await db
.update(metricFiles)
.set(updateData)
.where(and(eq(metricFiles.id, metricId), isNull(metricFiles.deletedAt)));
console.info(`Successfully updated metric ${metricId}`);
} catch (error) {
console.error(`Failed to update metric ${metricId}:`, error);
throw new Error(
`Failed to update metric: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};

View File

@ -9,8 +9,7 @@ export const UpdateMetricResponseSchema = MetricSchema;
export const DuplicateMetricResponseSchema = MetricSchema;
export const DeleteMetricResponseSchema = z.array(z.string());
export const ShareMetricResponseSchema = MetricSchema;
export const ShareDeleteResponseSchema = MetricSchema;
export const ShareUpdateResponseSchema = MetricSchema;
export const ShareMetricUpdateResponseSchema = MetricSchema;
export const BulkUpdateMetricVerificationStatusResponseSchema = z.object({
failed_updates: z.array(MetricSchema),
@ -36,8 +35,7 @@ export type BulkUpdateMetricVerificationStatusResponse = z.infer<
typeof BulkUpdateMetricVerificationStatusResponseSchema
>;
export type ShareMetricResponse = z.infer<typeof ShareMetricResponseSchema>;
export type ShareDeleteResponse = z.infer<typeof ShareDeleteResponseSchema>;
export type ShareUpdateResponse = z.infer<typeof ShareUpdateResponseSchema>;
export type ShareMetricUpdateResponse = z.infer<typeof ShareMetricUpdateResponseSchema>;
export type MetricDataResponse = z.infer<typeof MetricDataResponseSchema>;
/**

View File

@ -6,19 +6,6 @@ export const GetReportsListResponseSchema = PaginatedResponseSchema(ReportListIt
export const UpdateReportResponseSchema = ReportResponseSchema;
export const ShareUpdateResponseSchema = ReportResponseSchema;
// Sharing operation response schemas
export const SharePostResponseSchema = z.object({
success: z.boolean(),
shared: z.array(z.string()),
notFound: z.array(z.string()),
});
export const ShareDeleteResponseSchema = z.object({
success: z.boolean(),
removed: z.array(z.string()),
notFound: z.array(z.string()),
});
// For GET sharing endpoint - matches AssetPermissionWithUser from database
export const ShareGetResponseSchema = z.object({
permissions: z.array(
@ -51,6 +38,4 @@ export type GetReportsListResponse = z.infer<typeof GetReportsListResponseSchema
export type UpdateReportResponse = z.infer<typeof UpdateReportResponseSchema>;
export type GetReportResponse = z.infer<typeof ReportResponseSchema>;
export type ShareUpdateResponse = z.infer<typeof ShareUpdateResponseSchema>;
export type SharePostResponse = z.infer<typeof SharePostResponseSchema>;
export type ShareDeleteResponse = z.infer<typeof ShareDeleteResponseSchema>;
export type ShareGetResponse = z.infer<typeof ShareGetResponseSchema>;

View File

@ -3,3 +3,4 @@ export * from './verification.types';
export * from './requests';
export * from './individual-permissions';
export * from './assets';
export * from './responses';

View File

@ -0,0 +1,17 @@
import z from 'zod';
// Sharing operation response schemas
export const SharePostResponseSchema = z.object({
success: z.boolean(),
shared: z.array(z.string()),
notFound: z.array(z.string()),
});
export const ShareDeleteResponseSchema = z.object({
success: z.boolean(),
removed: z.array(z.string()),
notFound: z.array(z.string()),
});
export type SharePostResponse = z.infer<typeof SharePostResponseSchema>;
export type ShareDeleteResponse = z.infer<typeof ShareDeleteResponseSchema>;