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(),
});
const UpdateDefaultChannelSchema = z.object({
name: z.string().min(1),
id: z.string().min(1),
});
// Custom error class
export class SlackError extends Error {
constructor(
message: string,
public statusCode: 500 | 400 | 401 | 403 | 404 | 409 | 503 = 500,
public code?: string
public code?: string,
) {
super(message);
this.name = 'SlackError';
@ -72,7 +77,7 @@ export class SlackHandler {
error: 'Slack integration is not configured',
code: 'INTEGRATION_NOT_CONFIGURED',
},
503
503,
);
}
@ -83,7 +88,7 @@ export class SlackHandler {
error: 'Slack integration is not enabled',
code: 'INTEGRATION_DISABLED',
},
503
503,
);
}
@ -131,14 +136,14 @@ export class SlackHandler {
throw new SlackError(
'Organization already has an active Slack integration',
409,
'INTEGRATION_EXISTS'
'INTEGRATION_EXISTS',
);
}
throw new SlackError(
error instanceof Error ? error.message : 'Failed to initiate OAuth',
500,
'OAUTH_INIT_ERROR'
'OAUTH_INIT_ERROR',
);
}
}
@ -193,7 +198,7 @@ export class SlackHandler {
console.error('OAuth callback error:', error);
const errorMessage = error instanceof Error ? error.message : 'callback_failed';
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',
code: 'INTEGRATION_NOT_CONFIGURED',
},
503
503,
);
}
@ -243,7 +248,7 @@ export class SlackHandler {
throw new SlackError(
error instanceof Error ? error.message : 'Failed to get integration status',
500,
'GET_INTEGRATION_ERROR'
'GET_INTEGRATION_ERROR',
);
}
}
@ -264,7 +269,7 @@ export class SlackHandler {
error: 'Slack integration is not configured',
code: 'INTEGRATION_NOT_CONFIGURED',
},
503
503,
);
}
@ -282,14 +287,14 @@ export class SlackHandler {
const result = await slackOAuthService.removeIntegration(
organizationGrant.organizationId,
user.id
user.id,
);
if (!result.success) {
throw new SlackError(
result.error || 'Failed to remove integration',
404,
'INTEGRATION_NOT_FOUND'
'INTEGRATION_NOT_FOUND',
);
}
@ -306,7 +311,83 @@ export class SlackHandler {
throw new SlackError(
error instanceof Error ? error.message : 'Failed to remove integration',
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
.get('/integration', requireAuth, (c) => slackHandler.getIntegration(c))
.delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c))
.put('/integration/default-channel', requireAuth, (c) => slackHandler.updateDefaultChannel(c))
// Error handling
.onError((e, c) => {
if (e instanceof SlackError) {

View File

@ -9,7 +9,7 @@ export type SlackIntegration = InferSelectModel<typeof slackIntegrations>;
* Get active Slack integration for an organization
*/
export async function getActiveIntegration(
organizationId: string
organizationId: string,
): Promise<SlackIntegration | null> {
try {
const [integration] = await db
@ -19,8 +19,8 @@ export async function getActiveIntegration(
and(
eq(slackIntegrations.organizationId, organizationId),
eq(slackIntegrations.status, 'active'),
isNull(slackIntegrations.deletedAt)
)
isNull(slackIntegrations.deletedAt),
),
)
.limit(1);
@ -28,7 +28,9 @@ export async function getActiveIntegration(
} catch (error) {
console.error('Failed to get active Slack integration:', 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
*/
export async function getPendingIntegrationByState(
state: string
state: string,
): Promise<SlackIntegration | null> {
try {
const [integration] = await db
@ -47,8 +49,8 @@ export async function getPendingIntegrationByState(
and(
eq(slackIntegrations.oauthState, state),
eq(slackIntegrations.status, 'pending'),
gt(slackIntegrations.oauthExpiresAt, new Date().toISOString())
)
gt(slackIntegrations.oauthExpiresAt, new Date().toISOString()),
),
)
.limit(1);
@ -56,7 +58,9 @@ export async function getPendingIntegrationByState(
} catch (error) {
console.error('Failed to get pending integration by state:', 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) {
console.error('Failed to create pending Slack integration:', 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;
tokenVaultKey: string;
installedBySlackUserId?: string;
}
},
): Promise<void> {
try {
await db
@ -155,7 +161,7 @@ export async function updateIntegrationAfterOAuth(
} catch (error) {
console.error('Failed to update integration after OAuth:', 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(
integrationId: string,
_error?: string
_error?: string,
): Promise<void> {
try {
// Due to database constraint, we cannot mark a pending integration as failed
@ -206,7 +212,9 @@ export async function markIntegrationAsFailed(
} catch (error) {
console.error('Failed to mark integration as failed:', 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) {
console.error('Failed to soft delete integration:', 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) {
console.error('Failed to update last used timestamp:', 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) {
console.error('Failed to get integration by ID:', 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) {
console.error('Failed to check active integration:', 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)
*/
export async function getExistingIntegration(
organizationId: string
organizationId: string,
): Promise<SlackIntegration | null> {
try {
// Get the most recent non-deleted integration for this organization
@ -306,7 +322,34 @@ export async function getExistingIntegration(
} catch (error) {
console.error('Failed to get existing Slack integration:', 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(
and(
eq(slackIntegrations.status, 'pending'),
lt(slackIntegrations.oauthExpiresAt, new Date().toISOString())
)
lt(slackIntegrations.oauthExpiresAt, new Date().toISOString()),
),
);
// Clean up vault tokens for each expired integration
@ -345,8 +388,8 @@ export async function cleanupExpiredPendingIntegrations(): Promise<number> {
.where(
and(
eq(slackIntegrations.status, 'pending'),
lt(slackIntegrations.oauthExpiresAt, new Date().toISOString())
)
lt(slackIntegrations.oauthExpiresAt, new Date().toISOString()),
),
)
.returning({ id: slackIntegrations.id });
@ -354,7 +397,9 @@ export async function cleanupExpiredPendingIntegrations(): Promise<number> {
} catch (error) {
console.error('Failed to cleanup expired pending integrations:', 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