mirror of https://github.com/buster-so/buster.git
Add default channel support for Slack integration
Co-authored-by: dallin <dallin@buster.so>
This commit is contained in:
parent
0a51562ca6
commit
ba01e881e6
|
@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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'
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue