Implement shortcuts feature in chat and API routes

- Added shortcuts routes to the API.
- Enhanced chat handler to process shortcuts in user prompts.
- Introduced shortcuts table schema in the database.
- Updated relevant queries and shared types to include shortcuts.
- Configured environment variables for database connection.
This commit is contained in:
dal 2025-08-28 12:48:01 -06:00
parent 7159e7e8ea
commit 1b3150f466
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
30 changed files with 8632 additions and 13 deletions

View File

@ -9,6 +9,10 @@ import {
import { tasks } from '@trigger.dev/sdk';
import { handleAssetChat, handleAssetChatWithPrompt } from './services/chat-helpers';
import { initializeChat } from './services/chat-service';
import {
enhanceMessageWithShortcut,
enhanceMessageWithShortcutId,
} from './services/shortcut-service';
/**
* Handler function for creating a new chat.
@ -56,13 +60,37 @@ export async function createChatHandler(
throw new ChatError(ChatErrorCode.INVALID_REQUEST, 'prompt or asset_id is required', 400);
}
// Process shortcuts if present
let enhancedPrompt = request.prompt;
if (request.prompt) {
// Check for shortcut ID first (explicit shortcut reference)
if (request.shortcut_id) {
enhancedPrompt = await enhanceMessageWithShortcutId(
request.prompt,
request.shortcut_id,
user.id,
organizationId
);
} else {
// Check for shortcut pattern in message (e.g., /weekly-report)
enhancedPrompt = await enhanceMessageWithShortcut(request.prompt, user.id, organizationId);
}
}
// Update request with enhanced prompt
const processedRequest = { ...request, prompt: enhancedPrompt };
// Initialize chat (new or existing)
// When we have both asset and prompt, we'll skip creating the initial message
// since handleAssetChatWithPrompt will create both the import and prompt messages
const shouldCreateInitialMessage = !(request.asset_id && request.asset_type && request.prompt);
const shouldCreateInitialMessage = !(
processedRequest.asset_id &&
processedRequest.asset_type &&
processedRequest.prompt
);
const modifiedRequest = shouldCreateInitialMessage
? request
: { ...request, prompt: undefined };
? processedRequest
: { ...processedRequest, prompt: undefined };
const { chatId, messageId, chat } = await initializeChat(modifiedRequest, user, organizationId);
@ -71,14 +99,14 @@ export async function createChatHandler(
let actualMessageId = messageId; // Track the actual message ID to use for triggering
let shouldTriggerAnalyst = true; // Flag to control whether to trigger analyst task
if (request.asset_id && request.asset_type) {
if (!request.prompt) {
if (processedRequest.asset_id && processedRequest.asset_type) {
if (!processedRequest.prompt) {
// Original flow: just import the asset without a prompt
finalChat = await handleAssetChat(
chatId,
messageId,
request.asset_id,
request.asset_type,
processedRequest.asset_id,
processedRequest.asset_type,
user,
chat
);
@ -89,9 +117,9 @@ export async function createChatHandler(
finalChat = await handleAssetChatWithPrompt(
chatId,
messageId,
request.asset_id,
request.asset_type,
request.prompt,
processedRequest.asset_id,
processedRequest.asset_type,
processedRequest.prompt,
user,
chat
);
@ -104,7 +132,7 @@ export async function createChatHandler(
}
// Trigger background analysis only if we have a prompt or it's not an asset-only request
if (shouldTriggerAnalyst && (request.prompt || !request.asset_id)) {
if (shouldTriggerAnalyst && (processedRequest.prompt || !processedRequest.asset_id)) {
try {
// Just queue the background job - should be <100ms
const taskHandle = await tasks.trigger(
@ -146,8 +174,8 @@ export async function createChatHandler(
target: '500ms',
user,
chatId,
hasPrompt: !!request.prompt,
hasAsset: !!request.asset_id,
hasPrompt: !!processedRequest.prompt,
hasAsset: !!processedRequest.asset_id,
suggestion: 'Check database performance and trigger service',
});
}

View File

@ -0,0 +1,94 @@
import { findShortcutByName, getShortcutById } from '@buster/database';
import { ChatError, ChatErrorCode } from '@buster/server-shared/chats';
/**
* Parse a message to check if it starts with a shortcut pattern
* @param message The user's message
* @returns Object with shortcut name and additional context
*/
export function parseShortcutFromMessage(message: string): {
shortcutName: string | null;
additionalContext: string;
} {
// Match lowercase shortcut names with hyphens (e.g., /weekly-report)
const match = message.match(/^\/([a-z][a-z0-9-]*)\s?(.*)/);
if (!match) {
return { shortcutName: null, additionalContext: message };
}
return {
shortcutName: match[1],
additionalContext: match[2] || '',
};
}
/**
* Enhance a message with shortcut instructions
* @param message The original message
* @param userId The user's ID
* @param organizationId The organization's ID
* @returns Enhanced message with shortcut instructions
*/
export async function enhanceMessageWithShortcut(
message: string,
userId: string,
organizationId: string
): Promise<string> {
const { shortcutName, additionalContext } = parseShortcutFromMessage(message);
if (!shortcutName) {
return message;
}
const shortcut = await findShortcutByName({
name: shortcutName,
userId,
organizationId,
});
if (!shortcut) {
throw new ChatError(ChatErrorCode.INVALID_REQUEST, `Shortcut /${shortcutName} not found`, 404);
}
// Simply concatenate instructions with any additional context
return additionalContext
? `${shortcut.instructions} ${additionalContext}`
: shortcut.instructions;
}
/**
* Enhance a message with a specific shortcut ID
* @param message The original message (optional)
* @param shortcutId The shortcut ID to apply
* @param userId The user's ID
* @param organizationId The organization's ID
* @returns Enhanced message with shortcut instructions
*/
export async function enhanceMessageWithShortcutId(
message: string | undefined,
shortcutId: string,
userId: string,
organizationId: string
): Promise<string> {
const shortcut = await getShortcutById({ id: shortcutId });
if (!shortcut) {
throw new ChatError(ChatErrorCode.INVALID_REQUEST, 'Shortcut not found', 404);
}
// Check permissions: user must be in same org and either creator or shortcut is workspace-shared
if (
shortcut.organizationId !== organizationId ||
(!shortcut.sharedWithWorkspace && shortcut.createdBy !== userId)
) {
throw new ChatError(
ChatErrorCode.INVALID_REQUEST,
'You do not have permission to use this shortcut',
403
);
}
// Concatenate shortcut instructions with any additional message
return message ? `${shortcut.instructions} ${message}` : shortcut.instructions;
}

View File

@ -10,6 +10,7 @@ import organizationRoutes from './organization';
import reportsRoutes from './reports';
import s3IntegrationsRoutes from './s3-integrations';
import securityRoutes from './security';
import shortcutsRoutes from './shortcuts';
import slackRoutes from './slack';
import supportRoutes from './support';
import titleRoutes from './title';
@ -25,6 +26,7 @@ const app = new Hono()
.route('/slack', slackRoutes)
.route('/support', supportRoutes)
.route('/security', securityRoutes)
.route('/shortcuts', shortcutsRoutes)
.route('/organizations', organizationRoutes)
.route('/dictionaries', dictionariesRoutes)
.route('/title', titleRoutes)

View File

@ -0,0 +1,69 @@
import type { User } from '@buster/database';
import { checkDuplicateName, createShortcut, getUserOrganizationId } from '@buster/database';
import type { CreateShortcutRequest, Shortcut } from '@buster/server-shared/shortcuts';
import { HTTPException } from 'hono/http-exception';
export async function createShortcutHandler(
user: User,
data: CreateShortcutRequest
): Promise<Shortcut> {
try {
// Get user's organization ID
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(400, {
message: 'User must belong to an organization',
});
}
const { organizationId } = userOrg;
// Check if user has permission to create workspace shortcuts
if (data.sharedWithWorkspace) {
// TODO: Check if user is admin/has permission to create workspace shortcuts
// For now, we'll allow any authenticated user to create workspace shortcuts
// This should be updated based on your permission system
}
// Check for duplicate name
const isDuplicate = await checkDuplicateName({
name: data.name,
userId: user.id,
organizationId,
isWorkspace: data.sharedWithWorkspace,
});
if (isDuplicate) {
const scope = data.sharedWithWorkspace ? 'workspace' : 'your personal shortcuts';
throw new HTTPException(409, {
message: `A shortcut named '${data.name}' already exists in ${scope}`,
});
}
// Create the shortcut
const shortcut = await createShortcut({
name: data.name,
instructions: data.instructions,
createdBy: user.id,
organizationId,
sharedWithWorkspace: data.sharedWithWorkspace,
});
return shortcut;
} catch (error) {
console.error('Error in createShortcutHandler:', {
userId: user.id,
data,
error: error instanceof Error ? error.message : error,
});
if (error instanceof HTTPException) {
throw error;
}
throw new HTTPException(500, {
message: 'Failed to create shortcut',
});
}
}

View File

@ -0,0 +1,77 @@
import type { User } from '@buster/database';
import { deleteShortcut, getShortcutById, getUserOrganizationId } from '@buster/database';
import { HTTPException } from 'hono/http-exception';
export async function deleteShortcutHandler(
user: User,
shortcutId: string
): Promise<{ success: boolean }> {
try {
// Get user's organization ID
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(400, {
message: 'User must belong to an organization',
});
}
const { organizationId } = userOrg;
// Get the existing shortcut
const existingShortcut = await getShortcutById({ id: shortcutId });
if (!existingShortcut) {
throw new HTTPException(404, {
message: 'Shortcut not found',
});
}
// Check permissions
if (existingShortcut.organizationId !== organizationId) {
throw new HTTPException(403, {
message: 'You do not have permission to delete this shortcut',
});
}
// For personal shortcuts, only creator can delete
// For workspace shortcuts, check admin permission (TODO)
if (!existingShortcut.sharedWithWorkspace && existingShortcut.createdBy !== user.id) {
throw new HTTPException(403, {
message: 'You can only delete your own shortcuts',
});
}
if (existingShortcut.sharedWithWorkspace) {
// TODO: Check if user is admin/has permission to delete workspace shortcuts
// For now, we'll allow the creator to delete their workspace shortcuts
if (existingShortcut.createdBy !== user.id) {
throw new HTTPException(403, {
message: 'Only administrators can delete workspace shortcuts',
});
}
}
// Soft delete the shortcut
await deleteShortcut({
id: shortcutId,
deletedBy: user.id,
});
return { success: true };
} catch (error) {
console.error('Error in deleteShortcutHandler:', {
userId: user.id,
shortcutId,
error: error instanceof Error ? error.message : error,
});
if (error instanceof HTTPException) {
throw error;
}
throw new HTTPException(500, {
message: 'Failed to delete shortcut',
});
}
}

View File

@ -0,0 +1,53 @@
import type { User } from '@buster/database';
import { getShortcutById, getUserOrganizationId } from '@buster/database';
import type { Shortcut } from '@buster/server-shared/shortcuts';
import { HTTPException } from 'hono/http-exception';
export async function getShortcutHandler(user: User, shortcutId: string): Promise<Shortcut> {
try {
// Get user's organization ID
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(400, {
message: 'User must belong to an organization',
});
}
const { organizationId } = userOrg;
const shortcut = await getShortcutById({ id: shortcutId });
if (!shortcut) {
throw new HTTPException(404, {
message: 'Shortcut not found',
});
}
// Check permissions: user must be creator or shortcut must be workspace-shared
if (
shortcut.organizationId !== organizationId ||
(!shortcut.sharedWithWorkspace && shortcut.createdBy !== user.id)
) {
throw new HTTPException(403, {
message: 'You do not have permission to view this shortcut',
});
}
return shortcut;
} catch (error) {
console.error('Error in getShortcutHandler:', {
userId: user.id,
shortcutId,
error: error instanceof Error ? error.message : error,
});
if (error instanceof HTTPException) {
throw error;
}
throw new HTTPException(500, {
message: 'Failed to fetch shortcut',
});
}
}

View File

@ -0,0 +1,83 @@
import {
createShortcutRequestSchema,
updateShortcutRequestSchema,
} from '@buster/server-shared/shortcuts';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireAuth } from '../../../middleware/auth';
import '../../../types/hono.types';
import { HTTPException } from 'hono/http-exception';
import { createShortcutHandler } from './create-shortcut';
import { deleteShortcutHandler } from './delete-shortcut';
import { getShortcutHandler } from './get-shortcut';
import { listShortcutsHandler } from './list-shortcuts';
import { updateShortcutHandler } from './update-shortcut';
// Schema for path params
const shortcutIdParamSchema = z.object({
id: z.string().uuid(),
});
const app = new Hono()
// Apply authentication middleware
.use('*', requireAuth)
// List all accessible shortcuts (personal + workspace)
.get('/', async (c) => {
const user = c.get('busterUser');
const response = await listShortcutsHandler(user);
return c.json(response);
})
// Get a single shortcut by ID
.get('/:id', zValidator('param', shortcutIdParamSchema), async (c) => {
const user = c.get('busterUser');
const { id } = c.req.valid('param');
const response = await getShortcutHandler(user, id);
return c.json(response);
})
// Create a new shortcut
.post('/', zValidator('json', createShortcutRequestSchema), async (c) => {
const user = c.get('busterUser');
const data = c.req.valid('json');
const response = await createShortcutHandler(user, data);
return c.json(response, 201);
})
// Update an existing shortcut
.put(
'/:id',
zValidator('param', shortcutIdParamSchema),
zValidator('json', updateShortcutRequestSchema),
async (c) => {
const user = c.get('busterUser');
const { id } = c.req.valid('param');
const data = c.req.valid('json');
const response = await updateShortcutHandler(user, id, data);
return c.json(response);
}
)
// Delete a shortcut
.delete('/:id', zValidator('param', shortcutIdParamSchema), async (c) => {
const user = c.get('busterUser');
const { id } = c.req.valid('param');
const response = await deleteShortcutHandler(user, id);
return c.json(response);
})
// Error handling
.onError((e, _c) => {
if (e instanceof HTTPException) {
return e.getResponse();
}
console.error('Unhandled error in shortcuts API:', e);
throw new HTTPException(500, {
message: 'Internal server error',
});
});
export default app;

View File

@ -0,0 +1,42 @@
import type { User } from '@buster/database';
import { getUserShortcuts, getUserOrganizationId } from '@buster/database';
import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts';
import { HTTPException } from 'hono/http-exception';
export async function listShortcutsHandler(user: User): Promise<ListShortcutsResponse> {
try {
// Get user's organization ID
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(400, {
message: 'User must belong to an organization',
});
}
const { organizationId } = userOrg;
// Get all accessible shortcuts (personal + workspace) sorted alphabetically
const shortcuts = await getUserShortcuts({
userId: user.id,
organizationId,
});
return {
shortcuts,
};
} catch (error) {
console.error('Error in listShortcutsHandler:', {
userId: user.id,
error: error instanceof Error ? error.message : error,
});
if (error instanceof HTTPException) {
throw error;
}
throw new HTTPException(500, {
message: 'Failed to fetch shortcuts',
});
}
}

View File

@ -0,0 +1,108 @@
import type { User } from '@buster/database';
import { checkDuplicateName, getShortcutById, updateShortcut, getUserOrganizationId } from '@buster/database';
import type { Shortcut, UpdateShortcutRequest } from '@buster/server-shared/shortcuts';
import { HTTPException } from 'hono/http-exception';
export async function updateShortcutHandler(
user: User,
shortcutId: string,
data: UpdateShortcutRequest
): Promise<Shortcut> {
try {
// Get user's organization ID
const userOrg = await getUserOrganizationId(user.id);
if (!userOrg) {
throw new HTTPException(400, {
message: 'User must belong to an organization',
});
}
const { organizationId } = userOrg;
// Get the existing shortcut
const existingShortcut = await getShortcutById({ id: shortcutId });
if (!existingShortcut) {
throw new HTTPException(404, {
message: 'Shortcut not found',
});
}
// Check permissions
if (existingShortcut.organizationId !== organizationId) {
throw new HTTPException(403, {
message: 'You do not have permission to update this shortcut',
});
}
// For personal shortcuts, only creator can update
// For workspace shortcuts, check admin permission (TODO)
if (!existingShortcut.sharedWithWorkspace && existingShortcut.createdBy !== user.id) {
throw new HTTPException(403, {
message: 'You can only update your own shortcuts',
});
}
if (existingShortcut.sharedWithWorkspace) {
// TODO: Check if user is admin/has permission to update workspace shortcuts
// For now, we'll allow the creator to update their workspace shortcuts
if (existingShortcut.createdBy !== user.id) {
throw new HTTPException(403, {
message: 'Only administrators can update workspace shortcuts',
});
}
}
// If name is being changed, check for duplicates
if (data.name && data.name !== existingShortcut.name) {
const isDuplicate = await checkDuplicateName({
name: data.name,
userId: user.id,
organizationId,
isWorkspace: existingShortcut.sharedWithWorkspace,
excludeId: shortcutId,
});
if (isDuplicate) {
const scope = existingShortcut.sharedWithWorkspace
? 'workspace'
: 'your personal shortcuts';
throw new HTTPException(409, {
message: `A shortcut named '${data.name}' already exists in ${scope}`,
});
}
}
// Update the shortcut
const updatedShortcut = await updateShortcut({
id: shortcutId,
name: data.name,
instructions: data.instructions,
updatedBy: user.id,
});
if (!updatedShortcut) {
throw new HTTPException(500, {
message: 'Failed to update shortcut',
});
}
return updatedShortcut;
} catch (error) {
console.error('Error in updateShortcutHandler:', {
userId: user.id,
shortcutId,
data,
error: error instanceof Error ? error.message : error,
});
if (error instanceof HTTPException) {
throw error;
}
throw new HTTPException(500, {
message: 'Failed to update shortcut',
});
}
}

View File

@ -0,0 +1,378 @@
# Shortcuts API Examples
## Base URL
```
http://localhost:3000/api/v2/shortcuts
```
## Authentication
All endpoints require authentication. Include your auth token in the headers:
```json
{
"Authorization": "Bearer YOUR_AUTH_TOKEN"
}
```
---
## 1. Create a Personal Shortcut
### Request
```http
POST /api/v2/shortcuts
Content-Type: application/json
{
"name": "weekly-sales-report",
"instructions": "Build me a report that pulls in cumulative YTD sales for this week, total deals closed this week, and top rep of this week. I want you to highlight interesting trends and anomalies.",
"sharedWithWorkspace": false
}
```
### Response (201 Created)
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "weekly-sales-report",
"instructions": "Build me a report that pulls in cumulative YTD sales for this week, total deals closed this week, and top rep of this week. I want you to highlight interesting trends and anomalies.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": false,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T10:00:00.000Z",
"deletedAt": null
}
```
---
## 2. Create a Workspace Shortcut
### Request
```http
POST /api/v2/shortcuts
Content-Type: application/json
{
"name": "customer-analysis",
"instructions": "Analyze customer data focusing on: 1) Customer acquisition trends, 2) Churn rate analysis, 3) Customer lifetime value, 4) Segment performance. Present findings with clear visualizations.",
"sharedWithWorkspace": true
}
```
### Response (201 Created)
```json
{
"id": "456e7890-e89b-12d3-a456-426614174001",
"name": "customer-analysis",
"instructions": "Analyze customer data focusing on: 1) Customer acquisition trends, 2) Churn rate analysis, 3) Customer lifetime value, 4) Segment performance. Present findings with clear visualizations.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": true,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T10:00:00.000Z",
"deletedAt": null
}
```
---
## 3. List All Shortcuts (Personal + Workspace)
### Request
```http
GET /api/v2/shortcuts
```
### Response (200 OK)
```json
{
"shortcuts": [
{
"id": "456e7890-e89b-12d3-a456-426614174001",
"name": "customer-analysis",
"instructions": "Analyze customer data focusing on: 1) Customer acquisition trends, 2) Churn rate analysis, 3) Customer lifetime value, 4) Segment performance. Present findings with clear visualizations.",
"createdBy": "admin-user",
"updatedBy": "admin-user",
"organizationId": "org-456",
"sharedWithWorkspace": true,
"createdAt": "2024-01-15T09:00:00.000Z",
"updatedAt": "2024-01-15T09:00:00.000Z",
"deletedAt": null
},
{
"id": "789e0123-e89b-12d3-a456-426614174002",
"name": "daily-standup",
"instructions": "Generate a summary of: Yesterday's completed tasks, today's priorities, and any blockers. Format as bullet points.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": false,
"createdAt": "2024-01-14T14:30:00.000Z",
"updatedAt": "2024-01-14T14:30:00.000Z",
"deletedAt": null
},
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "weekly-sales-report",
"instructions": "Build me a report that pulls in cumulative YTD sales for this week, total deals closed this week, and top rep of this week. I want you to highlight interesting trends and anomalies.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": false,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T10:00:00.000Z",
"deletedAt": null
}
]
}
```
---
## 4. Get a Single Shortcut
### Request
```http
GET /api/v2/shortcuts/123e4567-e89b-12d3-a456-426614174000
```
### Response (200 OK)
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "weekly-sales-report",
"instructions": "Build me a report that pulls in cumulative YTD sales for this week, total deals closed this week, and top rep of this week. I want you to highlight interesting trends and anomalies.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": false,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T10:00:00.000Z",
"deletedAt": null
}
```
---
## 5. Update a Shortcut
### Request
```http
PUT /api/v2/shortcuts/123e4567-e89b-12d3-a456-426614174000
Content-Type: application/json
{
"name": "weekly-sales-summary",
"instructions": "Build me a comprehensive sales report including: 1) YTD cumulative sales, 2) Weekly deals closed with breakdown by type, 3) Top 5 performers with their key metrics, 4) Week-over-week comparison. Include charts for visual representation."
}
```
### Response (200 OK)
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "weekly-sales-summary",
"instructions": "Build me a comprehensive sales report including: 1) YTD cumulative sales, 2) Weekly deals closed with breakdown by type, 3) Top 5 performers with their key metrics, 4) Week-over-week comparison. Include charts for visual representation.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": false,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"deletedAt": null
}
```
---
## 6. Update Only Name
### Request
```http
PUT /api/v2/shortcuts/123e4567-e89b-12d3-a456-426614174000
Content-Type: application/json
{
"name": "sales-weekly"
}
```
### Response (200 OK)
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "sales-weekly",
"instructions": "Build me a comprehensive sales report including: 1) YTD cumulative sales, 2) Weekly deals closed with breakdown by type, 3) Top 5 performers with their key metrics, 4) Week-over-week comparison. Include charts for visual representation.",
"createdBy": "user-123",
"updatedBy": "user-123",
"organizationId": "org-456",
"sharedWithWorkspace": false,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T11:30:00.000Z",
"deletedAt": null
}
```
---
## 7. Delete a Shortcut
### Request
```http
DELETE /api/v2/shortcuts/123e4567-e89b-12d3-a456-426614174000
```
### Response (200 OK)
```json
{
"success": true
}
```
---
## Error Responses
### 409 Conflict - Duplicate Name
```json
{
"error": {
"message": "A shortcut named 'weekly-sales-report' already exists in your personal shortcuts"
}
}
```
### 404 Not Found
```json
{
"error": {
"message": "Shortcut not found"
}
}
```
### 403 Forbidden - Permission Denied
```json
{
"error": {
"message": "You can only update your own shortcuts"
}
}
```
### 400 Bad Request - Validation Error
```json
{
"error": {
"issues": [
{
"code": "invalid_string",
"message": "Name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens",
"path": ["name"]
}
]
}
}
```
---
## Chat Integration Examples
### Using Shortcut with Pattern in Chat Message
```http
POST /api/v2/chats
Content-Type: application/json
{
"prompt": "/weekly-sales-report for Q4 2024"
}
```
The system will:
1. Detect the `/weekly-sales-report` pattern
2. Look up the shortcut (personal first, then workspace)
3. Replace the message with: "Build me a report that pulls in cumulative YTD sales for this week, total deals closed this week, and top rep of this week. I want you to highlight interesting trends and anomalies. for Q4 2024"
### Using Shortcut with ID
```http
POST /api/v2/chats
Content-Type: application/json
{
"prompt": "Focus on enterprise customers only",
"shortcut_id": "123e4567-e89b-12d3-a456-426614174000"
}
```
The system will prepend the shortcut instructions to the prompt.
---
## Validation Rules
### Name Requirements
- Must start with a lowercase letter
- Can only contain lowercase letters, numbers, and hyphens
- Cannot have consecutive hyphens (--)
- Minimum 1 character, maximum 255 characters
- Examples of valid names:
- `weekly-report`
- `sales-q3`
- `customer-360-view`
- `kpi-dashboard`
### Invalid Names (will be rejected)
- `Weekly-Report` (uppercase letters)
- `weekly_report` (underscores not allowed)
- `weekly--report` (consecutive hyphens)
- `123-report` (starts with number)
- `report!` (special characters)
### Instructions Requirements
- Minimum 1 character
- Maximum 10,000 characters
- Can contain any text, including newlines and special characters
---
## CURL Examples
### Create Shortcut
```bash
curl -X POST http://localhost:3000/api/v2/shortcuts \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "market-analysis",
"instructions": "Analyze market trends and competitor positioning",
"sharedWithWorkspace": false
}'
```
### List Shortcuts
```bash
curl http://localhost:3000/api/v2/shortcuts \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Update Shortcut
```bash
curl -X PUT http://localhost:3000/api/v2/shortcuts/SHORTCUT_ID \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"instructions": "Updated analysis instructions with more detail"
}'
```
### Delete Shortcut
```bash
curl -X DELETE http://localhost:3000/api/v2/shortcuts/SHORTCUT_ID \
-H "Authorization: Bearer YOUR_TOKEN"
```

View File

@ -1,5 +1,9 @@
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';
// Load specific .env file
config({ path: '../../.env' }); // or '.env.development', '.env.production', etc.
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {

View File

@ -0,0 +1,20 @@
CREATE TABLE "shortcuts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"instructions" text NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid,
"organization_id" uuid NOT NULL,
"shared_with_workspace" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "shortcuts_personal_unique" UNIQUE("name","organization_id","created_by")
);
--> statement-breakpoint
ALTER TABLE "shortcuts" ADD CONSTRAINT "shortcuts_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "shortcuts" ADD CONSTRAINT "shortcuts_updated_by_fkey" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "shortcuts" ADD CONSTRAINT "shortcuts_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "shortcuts_org_user_idx" ON "shortcuts" USING btree ("organization_id" uuid_ops,"created_by" uuid_ops);--> statement-breakpoint
CREATE INDEX "shortcuts_name_idx" ON "shortcuts" USING btree ("name");--> statement-breakpoint
CREATE UNIQUE INDEX "shortcuts_workspace_unique" ON "shortcuts" USING btree ("name","organization_id") WHERE "shortcuts"."shared_with_workspace" = true;

File diff suppressed because it is too large Load Diff

View File

@ -638,6 +638,13 @@
"when": 1756098017282,
"tag": "0090_low_beast",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1756404533775,
"tag": "0091_sad_saracen",
"breakpoints": true
}
]
}

View File

@ -15,3 +15,4 @@ export * from './s3-integrations';
export * from './vault';
export * from './cascading-permissions';
export * from './github-integrations';
export * from './shortcuts';

View File

@ -0,0 +1,45 @@
import { and, eq, isNull, ne } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const CheckDuplicateNameInputSchema = z.object({
name: z.string().min(1).max(255),
userId: z.string().uuid(),
organizationId: z.string().uuid(),
isWorkspace: z.boolean(),
excludeId: z.string().uuid().optional(),
});
export type CheckDuplicateNameInput = z.infer<typeof CheckDuplicateNameInputSchema>;
export async function checkDuplicateName(input: CheckDuplicateNameInput): Promise<boolean> {
const validated = CheckDuplicateNameInputSchema.parse(input);
const conditions = [
eq(shortcuts.name, validated.name),
eq(shortcuts.organizationId, validated.organizationId),
isNull(shortcuts.deletedAt),
];
// Add exclude condition if updating
if (validated.excludeId) {
conditions.push(ne(shortcuts.id, validated.excludeId));
}
if (validated.isWorkspace) {
// Check for existing workspace shortcut
conditions.push(eq(shortcuts.sharedWithWorkspace, true));
} else {
// Check for existing personal shortcut
conditions.push(eq(shortcuts.createdBy, validated.userId));
}
const [existing] = await db
.select({ id: shortcuts.id })
.from(shortcuts)
.where(and(...conditions))
.limit(1);
return !!existing;
}

View File

@ -0,0 +1,66 @@
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const CreateShortcutInputSchema = z.object({
name: z.string().min(1).max(255),
instructions: z.string().min(1),
createdBy: z.string().uuid(),
organizationId: z.string().uuid(),
sharedWithWorkspace: z.boolean(),
});
export type CreateShortcutInput = z.infer<typeof CreateShortcutInputSchema>;
export async function createShortcut(input: CreateShortcutInput) {
const validated = CreateShortcutInputSchema.parse(input);
// Check if there's a soft-deleted shortcut with the same name
const [existingDeleted] = await db
.select()
.from(shortcuts)
.where(
and(
eq(shortcuts.name, validated.name),
eq(shortcuts.organizationId, validated.organizationId),
validated.sharedWithWorkspace
? eq(shortcuts.sharedWithWorkspace, true)
: eq(shortcuts.createdBy, validated.createdBy),
isNotNull(shortcuts.deletedAt)
)
)
.limit(1);
if (existingDeleted) {
// Upsert: restore the soft-deleted record with new data
const [restored] = await db
.update(shortcuts)
.set({
instructions: validated.instructions,
sharedWithWorkspace: validated.sharedWithWorkspace,
updatedBy: validated.createdBy,
updatedAt: new Date().toISOString(),
deletedAt: null,
})
.where(eq(shortcuts.id, existingDeleted.id))
.returning();
return restored;
}
// Create new shortcut
const [created] = await db
.insert(shortcuts)
.values({
name: validated.name,
instructions: validated.instructions,
createdBy: validated.createdBy,
updatedBy: validated.createdBy,
organizationId: validated.organizationId,
sharedWithWorkspace: validated.sharedWithWorkspace,
})
.returning();
return created;
}

View File

@ -0,0 +1,27 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const DeleteShortcutInputSchema = z.object({
id: z.string().uuid(),
deletedBy: z.string().uuid(),
});
export type DeleteShortcutInput = z.infer<typeof DeleteShortcutInputSchema>;
export async function deleteShortcut(input: DeleteShortcutInput) {
const validated = DeleteShortcutInputSchema.parse(input);
const [deleted] = await db
.update(shortcuts)
.set({
deletedAt: new Date().toISOString(),
updatedBy: validated.deletedBy,
updatedAt: new Date().toISOString(),
})
.where(and(eq(shortcuts.id, validated.id), isNull(shortcuts.deletedAt)))
.returning();
return deleted;
}

View File

@ -0,0 +1,50 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const FindShortcutByNameInputSchema = z.object({
name: z.string().min(1).max(255),
userId: z.string().uuid(),
organizationId: z.string().uuid(),
});
export type FindShortcutByNameInput = z.infer<typeof FindShortcutByNameInputSchema>;
export async function findShortcutByName(input: FindShortcutByNameInput) {
const validated = FindShortcutByNameInputSchema.parse(input);
// First try to find personal shortcut
const [personalShortcut] = await db
.select()
.from(shortcuts)
.where(
and(
eq(shortcuts.name, validated.name),
eq(shortcuts.createdBy, validated.userId),
eq(shortcuts.organizationId, validated.organizationId),
isNull(shortcuts.deletedAt)
)
)
.limit(1);
if (personalShortcut) {
return personalShortcut;
}
// Then try to find workspace shortcut
const [workspaceShortcut] = await db
.select()
.from(shortcuts)
.where(
and(
eq(shortcuts.name, validated.name),
eq(shortcuts.organizationId, validated.organizationId),
eq(shortcuts.sharedWithWorkspace, true),
isNull(shortcuts.deletedAt)
)
)
.limit(1);
return workspaceShortcut || null;
}

View File

@ -0,0 +1,22 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const GetShortcutByIdInputSchema = z.object({
id: z.string().uuid(),
});
export type GetShortcutByIdInput = z.infer<typeof GetShortcutByIdInputSchema>;
export async function getShortcutById(input: GetShortcutByIdInput) {
const validated = GetShortcutByIdInputSchema.parse(input);
const [shortcut] = await db
.select()
.from(shortcuts)
.where(and(eq(shortcuts.id, validated.id), isNull(shortcuts.deletedAt)))
.limit(1);
return shortcut || null;
}

View File

@ -0,0 +1,29 @@
import { and, asc, eq, isNull, or } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const GetUserShortcutsInputSchema = z.object({
userId: z.string().uuid(),
organizationId: z.string().uuid(),
});
export type GetUserShortcutsInput = z.infer<typeof GetUserShortcutsInputSchema>;
export async function getUserShortcuts(input: GetUserShortcutsInput) {
const validated = GetUserShortcutsInputSchema.parse(input);
const userShortcuts = await db
.select()
.from(shortcuts)
.where(
and(
eq(shortcuts.organizationId, validated.organizationId),
or(eq(shortcuts.createdBy, validated.userId), eq(shortcuts.sharedWithWorkspace, true)),
isNull(shortcuts.deletedAt)
)
)
.orderBy(asc(shortcuts.name));
return userShortcuts;
}

View File

@ -0,0 +1,16 @@
export { createShortcut } from './create-shortcut';
export { updateShortcut } from './update-shortcut';
export { deleteShortcut } from './delete-shortcut';
export { getShortcutById } from './get-shortcut-by-id';
export { getUserShortcuts } from './get-user-shortcuts';
export { findShortcutByName } from './find-shortcut-by-name';
export { checkDuplicateName } from './check-duplicate-name';
// Export types
export type { CreateShortcutInput } from './create-shortcut';
export type { UpdateShortcutInput } from './update-shortcut';
export type { DeleteShortcutInput } from './delete-shortcut';
export type { GetShortcutByIdInput } from './get-shortcut-by-id';
export type { GetUserShortcutsInput } from './get-user-shortcuts';
export type { FindShortcutByNameInput } from './find-shortcut-by-name';
export type { CheckDuplicateNameInput } from './check-duplicate-name';

View File

@ -0,0 +1,38 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { shortcuts } from '../../schema';
export const UpdateShortcutInputSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255).optional(),
instructions: z.string().min(1).optional(),
updatedBy: z.string().uuid(),
});
export type UpdateShortcutInput = z.infer<typeof UpdateShortcutInputSchema>;
export async function updateShortcut(input: UpdateShortcutInput) {
const validated = UpdateShortcutInputSchema.parse(input);
const updateData: Record<string, any> = {
updatedBy: validated.updatedBy,
updatedAt: new Date().toISOString(),
};
if (validated.name !== undefined) {
updateData.name = validated.name;
}
if (validated.instructions !== undefined) {
updateData.instructions = validated.instructions;
}
const [updated] = await db
.update(shortcuts)
.set(updateData)
.where(and(eq(shortcuts.id, validated.id), isNull(shortcuts.deletedAt)))
.returning();
return updated;
}

View File

@ -2285,3 +2285,54 @@ export const messagesToSlackMessages = pgTable(
),
]
);
export const shortcuts = pgTable(
'shortcuts',
{
id: uuid().defaultRandom().primaryKey().notNull(),
name: varchar({ length: 255 }).notNull(),
instructions: text().notNull(),
createdBy: uuid('created_by').notNull(),
updatedBy: uuid('updated_by'),
organizationId: uuid('organization_id').notNull(),
sharedWithWorkspace: boolean('shared_with_workspace').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
.defaultNow()
.notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }),
},
(table) => [
// Foreign keys
foreignKey({
columns: [table.createdBy],
foreignColumns: [users.id],
name: 'shortcuts_created_by_fkey',
}).onUpdate('cascade'),
foreignKey({
columns: [table.updatedBy],
foreignColumns: [users.id],
name: 'shortcuts_updated_by_fkey',
}).onUpdate('cascade'),
foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
name: 'shortcuts_organization_id_fkey',
}).onDelete('cascade'),
// Unique constraints
unique('shortcuts_personal_unique').on(table.name, table.organizationId, table.createdBy),
// Indexes
index('shortcuts_org_user_idx').using(
'btree',
table.organizationId.asc().nullsLast().op('uuid_ops'),
table.createdBy.asc().nullsLast().op('uuid_ops')
),
index('shortcuts_name_idx').using('btree', table.name.asc().nullsLast()),
// Conditional unique constraint for workspace shortcuts
uniqueIndex('shortcuts_workspace_unique')
.on(table.name, table.organizationId)
.where(sql`${table.sharedWithWorkspace} = true`),
]
);

View File

@ -79,6 +79,10 @@
"./s3-integrations": {
"types": "./dist/s3-integrations/index.d.ts",
"default": "./dist/s3-integrations/index.js"
},
"./shortcuts": {
"types": "./dist/shortcuts/index.d.ts",
"default": "./dist/shortcuts/index.js"
}
},
"module": "src/index.ts",

View File

@ -51,6 +51,7 @@ export const ChatCreateHandlerRequestSchema = z.object({
message_id: z.string().optional(),
asset_id: z.string().optional(),
asset_type: ChatAssetTypeSchema.optional(),
shortcut_id: z.string().uuid().optional(),
});
// Cancel chat params schema

View File

@ -22,3 +22,4 @@ export * from './teams';
export * from './title';
export * from './type-utilities';
export * from './user';
export * from './shortcuts';

View File

@ -0,0 +1,18 @@
// Export request types and schemas
export {
shortcutNameSchema,
createShortcutRequestSchema,
updateShortcutRequestSchema,
type CreateShortcutRequest,
type UpdateShortcutRequest,
} from './requests.types';
// Export response types and schemas
export {
shortcutSchema,
listShortcutsResponseSchema,
shortcutErrorSchema,
type Shortcut,
type ListShortcutsResponse,
type ShortcutError,
} from './responses.types';

View File

@ -0,0 +1,36 @@
import { z } from 'zod';
// Shortcut name validation: lowercase letters, numbers, and hyphens only
export const shortcutNameSchema = z
.string()
.min(1, 'Name is required')
.max(255, 'Name must be 255 characters or less')
.regex(/^[a-z][a-z0-9-]*$/, {
message:
'Name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens',
})
.refine((name) => !name.includes('--'), {
message: 'Name cannot contain consecutive hyphens',
});
export const createShortcutRequestSchema = z.object({
name: shortcutNameSchema,
instructions: z
.string()
.min(1, 'Instructions are required')
.max(10000, 'Instructions must be 10,000 characters or less'),
sharedWithWorkspace: z.boolean(),
});
export const updateShortcutRequestSchema = z.object({
name: shortcutNameSchema.optional(),
instructions: z
.string()
.min(1, 'Instructions are required')
.max(10000, 'Instructions must be 10,000 characters or less')
.optional(),
});
// Export types inferred from schemas
export type CreateShortcutRequest = z.infer<typeof createShortcutRequestSchema>;
export type UpdateShortcutRequest = z.infer<typeof updateShortcutRequestSchema>;

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
export const shortcutSchema = z.object({
id: z.string().uuid(),
name: z.string(),
instructions: z.string(),
createdBy: z.string().uuid(),
updatedBy: z.string().uuid().nullable(),
organizationId: z.string().uuid(),
sharedWithWorkspace: z.boolean(),
createdAt: z.string(), // ISO string
updatedAt: z.string(), // ISO string
deletedAt: z.string().nullable(), // ISO string or null
});
export const listShortcutsResponseSchema = z.object({
shortcuts: z.array(shortcutSchema),
});
export const shortcutErrorSchema = z.object({
code: z.enum(['DUPLICATE_NAME', 'NOT_FOUND', 'PERMISSION_DENIED']),
message: z.string(),
});
// Export types inferred from schemas
export type Shortcut = z.infer<typeof shortcutSchema>;
export type ListShortcutsResponse = z.infer<typeof listShortcutsResponseSchema>;
export type ShortcutError = z.infer<typeof shortcutErrorSchema>;