Add default channel support for Slack integration

Co-authored-by: dallin <dallin@buster.so>
This commit is contained in:
Cursor Agent 2025-07-03 20:47:18 +00:00
parent 0a51562ca6
commit ba01e881e6
5 changed files with 1936 additions and 1803 deletions

View File

@ -20,12 +20,17 @@ const OAuthCallbackSchema = z.object({
state: z.string(), state: z.string(),
}); });
const UpdateDefaultChannelSchema = z.object({
name: z.string().min(1),
id: z.string().min(1),
});
// Custom error class // Custom error class
export class SlackError extends Error { export class SlackError extends Error {
constructor( constructor(
message: string, message: string,
public statusCode: 500 | 400 | 401 | 403 | 404 | 409 | 503 = 500, public statusCode: 500 | 400 | 401 | 403 | 404 | 409 | 503 = 500,
public code?: string public code?: string,
) { ) {
super(message); super(message);
this.name = 'SlackError'; this.name = 'SlackError';
@ -72,7 +77,7 @@ export class SlackHandler {
error: 'Slack integration is not configured', error: 'Slack integration is not configured',
code: 'INTEGRATION_NOT_CONFIGURED', code: 'INTEGRATION_NOT_CONFIGURED',
}, },
503 503,
); );
} }
@ -83,7 +88,7 @@ export class SlackHandler {
error: 'Slack integration is not enabled', error: 'Slack integration is not enabled',
code: 'INTEGRATION_DISABLED', code: 'INTEGRATION_DISABLED',
}, },
503 503,
); );
} }
@ -131,14 +136,14 @@ export class SlackHandler {
throw new SlackError( throw new SlackError(
'Organization already has an active Slack integration', 'Organization already has an active Slack integration',
409, 409,
'INTEGRATION_EXISTS' 'INTEGRATION_EXISTS',
); );
} }
throw new SlackError( throw new SlackError(
error instanceof Error ? error.message : 'Failed to initiate OAuth', error instanceof Error ? error.message : 'Failed to initiate OAuth',
500, 500,
'OAUTH_INIT_ERROR' 'OAUTH_INIT_ERROR',
); );
} }
} }
@ -193,7 +198,7 @@ export class SlackHandler {
console.error('OAuth callback error:', error); console.error('OAuth callback error:', error);
const errorMessage = error instanceof Error ? error.message : 'callback_failed'; const errorMessage = error instanceof Error ? error.message : 'callback_failed';
return c.redirect( return c.redirect(
`/settings/integrations?status=error&error=${encodeURIComponent(errorMessage)}` `/settings/integrations?status=error&error=${encodeURIComponent(errorMessage)}`,
); );
} }
} }
@ -214,7 +219,7 @@ export class SlackHandler {
error: 'Slack integration is not configured', error: 'Slack integration is not configured',
code: 'INTEGRATION_NOT_CONFIGURED', code: 'INTEGRATION_NOT_CONFIGURED',
}, },
503 503,
); );
} }
@ -243,7 +248,7 @@ export class SlackHandler {
throw new SlackError( throw new SlackError(
error instanceof Error ? error.message : 'Failed to get integration status', error instanceof Error ? error.message : 'Failed to get integration status',
500, 500,
'GET_INTEGRATION_ERROR' 'GET_INTEGRATION_ERROR',
); );
} }
} }
@ -264,7 +269,7 @@ export class SlackHandler {
error: 'Slack integration is not configured', error: 'Slack integration is not configured',
code: 'INTEGRATION_NOT_CONFIGURED', code: 'INTEGRATION_NOT_CONFIGURED',
}, },
503 503,
); );
} }
@ -282,14 +287,14 @@ export class SlackHandler {
const result = await slackOAuthService.removeIntegration( const result = await slackOAuthService.removeIntegration(
organizationGrant.organizationId, organizationGrant.organizationId,
user.id user.id,
); );
if (!result.success) { if (!result.success) {
throw new SlackError( throw new SlackError(
result.error || 'Failed to remove integration', result.error || 'Failed to remove integration',
404, 404,
'INTEGRATION_NOT_FOUND' 'INTEGRATION_NOT_FOUND',
); );
} }
@ -306,7 +311,83 @@ export class SlackHandler {
throw new SlackError( throw new SlackError(
error instanceof Error ? error.message : 'Failed to remove integration', error instanceof Error ? error.message : 'Failed to remove integration',
500, 500,
'REMOVE_INTEGRATION_ERROR' 'REMOVE_INTEGRATION_ERROR',
);
}
}
/**
* PUT /api/v2/slack/integration/default-channel
* Update default channel for Slack integration
*/
async updateDefaultChannel(c: Context) {
try {
// Get service instance (lazy initialization)
const slackOAuthService = this.getSlackOAuthService();
// Check if service is available
if (!slackOAuthService) {
return c.json(
{
error: 'Slack integration is not configured',
code: 'INTEGRATION_NOT_CONFIGURED',
},
503,
);
}
const user = c.get('busterUser');
if (!user) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const organizationGrant = await getUserOrganizationId(user.id);
if (!organizationGrant) {
throw new HTTPException(400, { message: 'Organization not found' });
}
// Parse request body
const body = await c.req.json();
const parsed = UpdateDefaultChannelSchema.safeParse(body);
if (!parsed.success) {
throw new SlackError(
`Invalid request body: ${parsed.error.errors.map((e) => e.message).join(', ')}`,
400,
'INVALID_REQUEST_BODY',
);
}
// Get active integration
const { getActiveIntegration, updateDefaultChannel } = await import(
'./services/slack-helpers'
);
const integration = await getActiveIntegration(organizationGrant.organizationId);
if (!integration) {
throw new SlackError('No active Slack integration found', 404, 'INTEGRATION_NOT_FOUND');
}
// Update default channel
await updateDefaultChannel(integration.id, parsed.data);
return c.json({
message: 'Default channel updated successfully',
defaultChannel: parsed.data,
});
} catch (error) {
console.error('Failed to update default channel:', error);
if (error instanceof HTTPException || error instanceof SlackError) {
throw error;
}
throw new SlackError(
error instanceof Error ? error.message : 'Failed to update default channel',
500,
'UPDATE_DEFAULT_CHANNEL_ERROR',
); );
} }
} }

View File

@ -10,6 +10,7 @@ const app = new Hono()
// Protected endpoints // Protected endpoints
.get('/integration', requireAuth, (c) => slackHandler.getIntegration(c)) .get('/integration', requireAuth, (c) => slackHandler.getIntegration(c))
.delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c)) .delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c))
.put('/integration/default-channel', requireAuth, (c) => slackHandler.updateDefaultChannel(c))
// Error handling // Error handling
.onError((e, c) => { .onError((e, c) => {
if (e instanceof SlackError) { if (e instanceof SlackError) {

View File

@ -9,7 +9,7 @@ export type SlackIntegration = InferSelectModel<typeof slackIntegrations>;
* Get active Slack integration for an organization * Get active Slack integration for an organization
*/ */
export async function getActiveIntegration( export async function getActiveIntegration(
organizationId: string organizationId: string,
): Promise<SlackIntegration | null> { ): Promise<SlackIntegration | null> {
try { try {
const [integration] = await db const [integration] = await db
@ -19,8 +19,8 @@ export async function getActiveIntegration(
and( and(
eq(slackIntegrations.organizationId, organizationId), eq(slackIntegrations.organizationId, organizationId),
eq(slackIntegrations.status, 'active'), eq(slackIntegrations.status, 'active'),
isNull(slackIntegrations.deletedAt) isNull(slackIntegrations.deletedAt),
) ),
) )
.limit(1); .limit(1);
@ -28,7 +28,9 @@ export async function getActiveIntegration(
} catch (error) { } catch (error) {
console.error('Failed to get active Slack integration:', error); console.error('Failed to get active Slack integration:', error);
throw new Error( throw new Error(
`Failed to get active Slack integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to get active Slack integration: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -37,7 +39,7 @@ export async function getActiveIntegration(
* Get pending integration by OAuth state * Get pending integration by OAuth state
*/ */
export async function getPendingIntegrationByState( export async function getPendingIntegrationByState(
state: string state: string,
): Promise<SlackIntegration | null> { ): Promise<SlackIntegration | null> {
try { try {
const [integration] = await db const [integration] = await db
@ -47,8 +49,8 @@ export async function getPendingIntegrationByState(
and( and(
eq(slackIntegrations.oauthState, state), eq(slackIntegrations.oauthState, state),
eq(slackIntegrations.status, 'pending'), eq(slackIntegrations.status, 'pending'),
gt(slackIntegrations.oauthExpiresAt, new Date().toISOString()) gt(slackIntegrations.oauthExpiresAt, new Date().toISOString()),
) ),
) )
.limit(1); .limit(1);
@ -56,7 +58,9 @@ export async function getPendingIntegrationByState(
} catch (error) { } catch (error) {
console.error('Failed to get pending integration by state:', error); console.error('Failed to get pending integration by state:', error);
throw new Error( throw new Error(
`Failed to get pending integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to get pending integration: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -118,7 +122,9 @@ export async function createPendingIntegration(params: {
} catch (error) { } catch (error) {
console.error('Failed to create pending Slack integration:', error); console.error('Failed to create pending Slack integration:', error);
throw new Error( throw new Error(
`Failed to create pending integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to create pending integration: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -137,7 +143,7 @@ export async function updateIntegrationAfterOAuth(
scope: string; scope: string;
tokenVaultKey: string; tokenVaultKey: string;
installedBySlackUserId?: string; installedBySlackUserId?: string;
} },
): Promise<void> { ): Promise<void> {
try { try {
await db await db
@ -155,7 +161,7 @@ export async function updateIntegrationAfterOAuth(
} catch (error) { } catch (error) {
console.error('Failed to update integration after OAuth:', error); console.error('Failed to update integration after OAuth:', error);
throw new Error( throw new Error(
`Failed to activate integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to activate integration: ${error instanceof Error ? error.message : 'Unknown error'}`,
); );
} }
} }
@ -165,7 +171,7 @@ export async function updateIntegrationAfterOAuth(
*/ */
export async function markIntegrationAsFailed( export async function markIntegrationAsFailed(
integrationId: string, integrationId: string,
_error?: string _error?: string,
): Promise<void> { ): Promise<void> {
try { try {
// Due to database constraint, we cannot mark a pending integration as failed // Due to database constraint, we cannot mark a pending integration as failed
@ -206,7 +212,9 @@ export async function markIntegrationAsFailed(
} catch (error) { } catch (error) {
console.error('Failed to mark integration as failed:', error); console.error('Failed to mark integration as failed:', error);
throw new Error( throw new Error(
`Failed to mark integration as failed: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to mark integration as failed: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -227,7 +235,9 @@ export async function softDeleteIntegration(integrationId: string): Promise<void
} catch (error) { } catch (error) {
console.error('Failed to soft delete integration:', error); console.error('Failed to soft delete integration:', error);
throw new Error( throw new Error(
`Failed to soft delete integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to soft delete integration: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -247,7 +257,9 @@ export async function updateLastUsedAt(integrationId: string): Promise<void> {
} catch (error) { } catch (error) {
console.error('Failed to update last used timestamp:', error); console.error('Failed to update last used timestamp:', error);
throw new Error( throw new Error(
`Failed to update last used timestamp: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to update last used timestamp: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -267,7 +279,9 @@ export async function getIntegrationById(integrationId: string): Promise<SlackIn
} catch (error) { } catch (error) {
console.error('Failed to get integration by ID:', error); console.error('Failed to get integration by ID:', error);
throw new Error( throw new Error(
`Failed to get integration by ID: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to get integration by ID: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -282,7 +296,9 @@ export async function hasActiveIntegration(organizationId: string): Promise<bool
} catch (error) { } catch (error) {
console.error('Failed to check active integration:', error); console.error('Failed to check active integration:', error);
throw new Error( throw new Error(
`Failed to check active integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to check active integration: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -291,7 +307,7 @@ export async function hasActiveIntegration(organizationId: string): Promise<bool
* Get any existing integration for an organization (active, revoked, or failed) * Get any existing integration for an organization (active, revoked, or failed)
*/ */
export async function getExistingIntegration( export async function getExistingIntegration(
organizationId: string organizationId: string,
): Promise<SlackIntegration | null> { ): Promise<SlackIntegration | null> {
try { try {
// Get the most recent non-deleted integration for this organization // Get the most recent non-deleted integration for this organization
@ -306,7 +322,34 @@ export async function getExistingIntegration(
} catch (error) { } catch (error) {
console.error('Failed to get existing Slack integration:', error); console.error('Failed to get existing Slack integration:', error);
throw new Error( throw new Error(
`Failed to get existing Slack integration: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to get existing Slack integration: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
);
}
}
/**
* Update default channel for Slack integration
*/
export async function updateDefaultChannel(
integrationId: string,
defaultChannel: { name: string; id: string },
): Promise<void> {
try {
await db
.update(slackIntegrations)
.set({
defaultChannel,
updatedAt: new Date().toISOString(),
})
.where(eq(slackIntegrations.id, integrationId));
} catch (error) {
console.error('Failed to update default channel:', error);
throw new Error(
`Failed to update default channel: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }
@ -323,8 +366,8 @@ export async function cleanupExpiredPendingIntegrations(): Promise<number> {
.where( .where(
and( and(
eq(slackIntegrations.status, 'pending'), eq(slackIntegrations.status, 'pending'),
lt(slackIntegrations.oauthExpiresAt, new Date().toISOString()) lt(slackIntegrations.oauthExpiresAt, new Date().toISOString()),
) ),
); );
// Clean up vault tokens for each expired integration // Clean up vault tokens for each expired integration
@ -345,8 +388,8 @@ export async function cleanupExpiredPendingIntegrations(): Promise<number> {
.where( .where(
and( and(
eq(slackIntegrations.status, 'pending'), eq(slackIntegrations.status, 'pending'),
lt(slackIntegrations.oauthExpiresAt, new Date().toISOString()) lt(slackIntegrations.oauthExpiresAt, new Date().toISOString()),
) ),
) )
.returning({ id: slackIntegrations.id }); .returning({ id: slackIntegrations.id });
@ -354,7 +397,9 @@ export async function cleanupExpiredPendingIntegrations(): Promise<number> {
} catch (error) { } catch (error) {
console.error('Failed to cleanup expired pending integrations:', error); console.error('Failed to cleanup expired pending integrations:', error);
throw new Error( throw new Error(
`Failed to cleanup expired integrations: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to cleanup expired integrations: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
); );
} }
} }

View File

@ -0,0 +1,3 @@
-- Add default_channel column to slack_integrations table
ALTER TABLE "slack_integrations"
ADD COLUMN "default_channel" jsonb DEFAULT '{}'::jsonb;

File diff suppressed because it is too large Load Diff