mirror of https://github.com/buster-so/buster.git
get rid of migration
This commit is contained in:
commit
6b86f561aa
24
AGENT.md
24
AGENT.md
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -282,13 +282,6 @@ export const useUnshareMetric = () => {
|
|||
});
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const upgradedMetric = upgradeMetricToIMetric(data, null);
|
||||
queryClient.setQueryData(
|
||||
metricsQueryKeys.metricsGetMetric(data.id, 'LATEST').queryKey,
|
||||
upgradedMetric
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ export {
|
|||
updateChat,
|
||||
getChatWithDetails,
|
||||
createMessage,
|
||||
updateChatSharing,
|
||||
getChatById,
|
||||
CreateChatInputSchema,
|
||||
GetChatInputSchema,
|
||||
CreateMessageInputSchema,
|
||||
|
|
|
@ -16,6 +16,8 @@ export {
|
|||
type GetDashboardByIdInput,
|
||||
} from './get-dashboard-by-id';
|
||||
|
||||
export { updateDashboard } from './update-dashboard';
|
||||
|
||||
export {
|
||||
getCollectionsAssociatedWithDashboard,
|
||||
type AssociatedCollection,
|
||||
|
|
|
@ -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'}`
|
||||
);
|
||||
}
|
||||
};
|
|
@ -32,6 +32,8 @@ export {
|
|||
type MetricFile,
|
||||
} from './get-metric-by-id';
|
||||
|
||||
export { updateMetric } from './update-metric';
|
||||
|
||||
export {
|
||||
getDashboardsAssociatedWithMetric,
|
||||
getCollectionsAssociatedWithMetric,
|
||||
|
|
|
@ -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'}`
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -3,3 +3,4 @@ export * from './verification.types';
|
|||
export * from './requests';
|
||||
export * from './individual-permissions';
|
||||
export * from './assets';
|
||||
export * from './responses';
|
||||
|
|
|
@ -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>;
|
Loading…
Reference in New Issue