diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 840efc5ff7f..b7e2491b8b0 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -189,6 +189,9 @@ jobs: LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} GH_APP_ID: ${{ secrets.GH_APP_ID }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -229,6 +232,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env SLACK_CLIENT_ID=$SLACK_CLIENT_ID \ + --env SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET \ + --env SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET \ --env GH_APP_ID="$GH_APP_ID" \ --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index ad96f6c91b5..b3b68d22840 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -93,6 +93,9 @@ jobs: LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} GH_APP_ID: ${{ secrets.GH_APP_ID }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -133,6 +136,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env SLACK_CLIENT_ID=$SLACK_CLIENT_ID \ + --env SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET \ + --env SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET \ --env GH_APP_ID="$GH_APP_ID" \ --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ diff --git a/apps/api/package.json b/apps/api/package.json index ad0a03f780c..9ac03eee334 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.25.3", @@ -18,8 +19,11 @@ "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.36.0", + "@slack/types": "^2.19.0", + "@slack/web-api": "^7.13.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/mcp": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index da35f47e3da..149bd5114dc 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -1,8 +1,8 @@ import { auth } from "@superset/auth/server"; +import { registerTools } from "@superset/mcp"; +import type { McpContext } from "@superset/mcp/auth"; import { createMcpHandler, withMcpAuth } from "mcp-handler"; import { env } from "@/env"; -import type { McpContext } from "@/lib/mcp/auth"; -import { registerTools } from "@/lib/mcp/tools"; async function verifyToken(req: Request, bearerToken?: string) { // 1. Try session auth diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts new file mode 100644 index 00000000000..7b8781d8064 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -0,0 +1,116 @@ +import { WebClient } from "@slack/web-api"; +import { db } from "@superset/db/client"; +import type { SlackConfig } from "@superset/db/schema"; +import { integrationConnections, members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { env } from "@/env"; +import { verifySignedState } from "@/lib/oauth-state"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=oauth_denied`, + ); + } + + if (!code || !state) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`, + ); + } + + const stateData = verifySignedState(state); + if (!stateData) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=invalid_state`, + ); + } + + const { organizationId, userId } = stateData; + + // Re-verify membership at callback time (state was signed earlier) + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + console.error("[slack/callback] Membership verification failed:", { + organizationId, + userId, + }); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=unauthorized`, + ); + } + + const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + const client = new WebClient(); + + try { + const tokenData = await client.oauth.v2.access({ + client_id: env.SLACK_CLIENT_ID, + client_secret: env.SLACK_CLIENT_SECRET, + redirect_uri: redirectUri, + code, + }); + + if (!tokenData.ok || !tokenData.access_token || !tokenData.team) { + console.error("[slack/callback] Slack API error:", tokenData.error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`, + ); + } + + const config: SlackConfig = { + provider: "slack", + }; + + await db + .insert(integrationConnections) + .values({ + organizationId, + connectedByUserId: userId, + provider: "slack", + accessToken: tokenData.access_token, + externalOrgId: tokenData.team.id, + externalOrgName: tokenData.team.name, + config, + }) + .onConflictDoUpdate({ + target: [ + integrationConnections.organizationId, + integrationConnections.provider, + ], + set: { + accessToken: tokenData.access_token, + externalOrgId: tokenData.team.id, + externalOrgName: tokenData.team.name, + connectedByUserId: userId, + config, + updatedAt: new Date(), + }, + }); + + console.log("[slack/callback] Connected workspace:", { + organizationId, + teamId: tokenData.team.id, + teamName: tokenData.team.name, + }); + + return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`); + } catch (error) { + console.error("[slack/callback] Token exchange failed:", error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`, + ); + } +} diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts new file mode 100644 index 00000000000..badee4495b6 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -0,0 +1,73 @@ +import { auth } from "@superset/auth/server"; +import { db } from "@superset/db/client"; +import { members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { env } from "@/env"; +import { createSignedState } from "@/lib/oauth-state"; + +const SLACK_SCOPES = [ + "app_mentions:read", + "chat:write", + "reactions:write", + "channels:history", + "groups:history", + "im:history", + "im:read", + "im:write", + "mpim:history", + "users:read", + "assistant:write", + "links:read", + "links:write", +].join(","); + +export async function GET(request: Request) { + const url = new URL(request.url); + const organizationId = url.searchParams.get("organizationId"); + if (!organizationId) { + return Response.json( + { error: "Missing organizationId parameter" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + return Response.json( + { error: "User is not a member of this organization" }, + { status: 403 }, + ); + } + + const state = createSignedState({ + organizationId, + userId, + }); + + const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + + const slackAuthUrl = new URL("https://slack.com/oauth/v2/authorize"); + slackAuthUrl.searchParams.set("client_id", env.SLACK_CLIENT_ID); + slackAuthUrl.searchParams.set("redirect_uri", redirectUri); + slackAuthUrl.searchParams.set("scope", SLACK_SCOPES); + slackAuthUrl.searchParams.set("state", state); + + return Response.redirect(slackAuthUrl.toString()); +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts new file mode 100644 index 00000000000..f83d336775b --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts @@ -0,0 +1 @@ +export { processAssistantMessage } from "./process-assistant-message"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts new file mode 100644 index 00000000000..b0536dfcb89 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts @@ -0,0 +1,97 @@ +import type { GenericMessageEvent } from "@slack/types"; +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import { runSlackAgent } from "../utils/run-agent"; +import { formatActionsAsText } from "../utils/slack-blocks"; +import { createSlackClient } from "../utils/slack-client"; + +interface ProcessAssistantMessageParams { + event: GenericMessageEvent; + teamId: string; + eventId: string; +} + +export async function processAssistantMessage({ + event, + teamId, + eventId, +}: ProcessAssistantMessageParams): Promise { + console.log("[slack/process-assistant-message] Processing message:", { + eventId, + teamId, + channel: event.channel, + user: event.user, + }); + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-assistant-message] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + const threadTs = event.thread_ts ?? event.ts; + + try { + await slack.assistant.threads.setStatus({ + channel_id: event.channel, + thread_ts: threadTs, + status: "Thinking...", + }); + } catch (err) { + console.warn( + "[slack/process-assistant-message] Failed to set status:", + err, + ); + } + + try { + const result = await runSlackAgent({ + prompt: event.text ?? "", + channelId: event.channel, + threadTs, + organizationId: connection.organizationId, + slackToken: connection.accessToken, + slackTeamId: teamId, + }); + + // Format actions as text with URLs (enables Slack unfurling) + const hasActions = result.actions.length > 0; + const responseText = hasActions + ? formatActionsAsText(result.actions) + : result.text; + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: responseText, + }); + } catch (err) { + console.error("[slack/process-assistant-message] Agent error:", err); + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, + }); + } finally { + try { + await slack.assistant.threads.setStatus({ + channel_id: event.channel, + thread_ts: threadTs, + status: "", + }); + } catch {} + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts new file mode 100644 index 00000000000..7f6c2b4c06d --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts @@ -0,0 +1 @@ +export { processEntityDetails } from "./process-entity-details"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts new file mode 100644 index 00000000000..895434309e3 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts @@ -0,0 +1,123 @@ +import type { SlackEvent } from "@slack/types"; +import { db } from "@superset/db/client"; +import { integrationConnections, tasks } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import { createSlackClient } from "../utils/slack-client"; +import { + createTaskFlexpaneObject, + parseTaskSlugFromUrl, +} from "../utils/work-objects"; + +type EntityDetailsRequestedEvent = Extract< + SlackEvent, + { type: "entity_details_requested" } +>; + +interface ProcessEntityDetailsParams { + event: EntityDetailsRequestedEvent; + teamId: string; + eventId: string; +} + +/** Populates the flexpane when a user clicks an unfurled Work Object. */ +export async function processEntityDetails({ + event, + teamId, + eventId, +}: ProcessEntityDetailsParams): Promise { + console.log("[slack/process-entity-details] Processing entity details:", { + eventId, + teamId, + entityUrl: event.entity_url, + externalRef: event.external_ref, + }); + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-entity-details] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + const taskSlug = parseTaskSlugFromUrl(event.entity_url); + + if (!taskSlug) { + console.error( + "[slack/process-entity-details] Could not parse task slug from URL:", + event.entity_url, + ); + + try { + await slack.entity.presentDetails({ + trigger_id: event.trigger_id, + error: { + status: "not_found", + custom_message: "Could not find the requested task.", + }, + }); + } catch (err) { + console.error( + "[slack/process-entity-details] Failed to send error response:", + err, + ); + } + return; + } + + const task = await db.query.tasks.findFirst({ + where: and( + eq(tasks.organizationId, connection.organizationId), + eq(tasks.slug, taskSlug), + ), + with: { + status: true, + assignee: true, + creator: true, + organization: true, + }, + }); + + if (!task) { + console.error("[slack/process-entity-details] Task not found:", taskSlug); + + try { + await slack.entity.presentDetails({ + trigger_id: event.trigger_id, + error: { + status: "not_found", + custom_message: `Task "${taskSlug}" was not found.`, + }, + }); + } catch (err) { + console.error( + "[slack/process-entity-details] Failed to send error response:", + err, + ); + } + return; + } + + const entity = createTaskFlexpaneObject(task); + + try { + await slack.entity.presentDetails({ + trigger_id: event.trigger_id, + metadata: entity, + }); + } catch (err) { + console.error( + "[slack/process-entity-details] Failed to present details:", + err, + ); + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts new file mode 100644 index 00000000000..de3fb8e7237 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts @@ -0,0 +1 @@ +export { processLinkShared } from "./process-link-shared"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts new file mode 100644 index 00000000000..d63e54d6148 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts @@ -0,0 +1,87 @@ +import type { EntityMetadata, LinkSharedEvent } from "@slack/types"; +import { db } from "@superset/db/client"; +import { integrationConnections, tasks } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import { createSlackClient } from "../utils/slack-client"; +import { + createTaskWorkObject, + parseTaskSlugFromUrl, +} from "../utils/work-objects"; + +interface ProcessLinkSharedParams { + event: LinkSharedEvent; + teamId: string; + eventId: string; +} + +export async function processLinkShared({ + event, + teamId, + eventId, +}: ProcessLinkSharedParams): Promise { + console.log("[slack/process-link-shared] Processing links:", { + eventId, + teamId, + linkCount: event.links.length, + }); + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-link-shared] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + const entities: EntityMetadata[] = []; + + for (const link of event.links) { + const taskSlug = parseTaskSlugFromUrl(link.url); + if (!taskSlug) { + continue; + } + + const task = await db.query.tasks.findFirst({ + where: and( + eq(tasks.organizationId, connection.organizationId), + eq(tasks.slug, taskSlug), + ), + with: { + status: true, + assignee: true, + creator: true, + }, + }); + + if (task) { + const entity = createTaskWorkObject(task); + // Must match the exact URL from the message for Slack to unfurl + entity.app_unfurl_url = link.url; + entities.push(entity); + } + } + + if (entities.length > 0) { + try { + // Work Objects use `metadata` instead of the legacy `unfurls` field + await slack.chat.unfurl({ + channel: event.channel, + ts: event.message_ts, + metadata: { + entities, + }, + }); + } catch (err) { + console.error("[slack/process-link-shared] Failed to send unfurls:", err); + } + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention/index.ts b/apps/api/src/app/api/integrations/slack/events/process-mention/index.ts new file mode 100644 index 00000000000..5a7dbc2681b --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-mention/index.ts @@ -0,0 +1 @@ +export { processSlackMention } from "./process-mention"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts new file mode 100644 index 00000000000..4d675d9ce65 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts @@ -0,0 +1,94 @@ +import type { AppMentionEvent } from "@slack/types"; +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import { runSlackAgent } from "../utils/run-agent"; +import { formatActionsAsText } from "../utils/slack-blocks"; +import { createSlackClient } from "../utils/slack-client"; + +interface ProcessMentionParams { + event: AppMentionEvent; + teamId: string; + eventId: string; +} + +export async function processSlackMention({ + event, + teamId, + eventId, +}: ProcessMentionParams): Promise { + console.log("[slack/process-mention] Processing mention:", { + eventId, + teamId, + channel: event.channel, + user: event.user, + }); + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-mention] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + try { + await slack.reactions.add({ + channel: event.channel, + timestamp: event.ts, + name: "eyes", + }); + } catch (err) { + console.warn("[slack/process-mention] Failed to add reaction:", err); + } + + const threadTs = event.thread_ts ?? event.ts; + + try { + const result = await runSlackAgent({ + prompt: event.text, + channelId: event.channel, + threadTs, + organizationId: connection.organizationId, + slackToken: connection.accessToken, + slackTeamId: teamId, + }); + + // Format actions as text with URLs (enables Slack unfurling) + const hasActions = result.actions.length > 0; + const responseText = hasActions + ? formatActionsAsText(result.actions) + : result.text; + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: responseText, + }); + } catch (err) { + console.error("[slack/process-mention] Agent error:", err); + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, + }); + } finally { + try { + await slack.reactions.remove({ + channel: event.channel, + timestamp: event.ts, + name: "eyes", + }); + } catch {} + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts new file mode 100644 index 00000000000..09314d45740 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -0,0 +1,130 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { Client } from "@upstash/qstash"; + +import { env } from "@/env"; +import { processEntityDetails } from "./process-entity-details"; +import { processLinkShared } from "./process-link-shared"; + +const qstash = new Client({ token: env.QSTASH_TOKEN }); + +function verifySlackSignature({ + body, + signature, + timestamp, +}: { + body: string; + signature: string; + timestamp: string; +}): boolean { + // Reject timestamps >5 min old to prevent replay attacks + const timestampSec = Number.parseInt(timestamp, 10); + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestampSec) > 60 * 5) { + console.error("[slack/events] Timestamp too old or in future"); + return false; + } + + const sigBase = `v0:${timestamp}:${body}`; + const mySignature = `v0=${createHmac("sha256", env.SLACK_SIGNING_SECRET).update(sigBase).digest("hex")}`; + + try { + return timingSafeEqual( + Buffer.from(mySignature, "utf8"), + Buffer.from(signature, "utf8"), + ); + } catch { + return false; + } +} + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("x-slack-signature"); + const timestamp = request.headers.get("x-slack-request-timestamp"); + + if (!signature || !timestamp) { + return Response.json( + { error: "Missing signature headers" }, + { status: 401 }, + ); + } + + if (!verifySlackSignature({ body, signature, timestamp })) { + console.error("[slack/events] Signature verification failed"); + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const payload = JSON.parse(body); + + // Slack sends this once when configuring the Events URL + if (payload.type === "url_verification") { + return Response.json({ challenge: payload.challenge }); + } + + if (payload.type === "event_callback") { + const { event, team_id, event_id } = payload; + + if (event.type === "app_mention") { + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, + }); + } catch (error) { + console.error("[slack/events] Failed to queue mention job:", error); + } + } + + if (event.type === "message" && event.channel_type === "im") { + // Skip bot messages to prevent infinite loops + if (event.bot_id || event.subtype === "bot_message" || !event.user) { + return new Response("ok", { status: 200 }); + } + + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, + }); + } catch (error) { + console.error( + "[slack/events] Failed to queue assistant message job:", + error, + ); + } + } + + if (event.type === "link_shared") { + processLinkShared({ + event, + teamId: team_id, + eventId: event_id, + }).catch((err: unknown) => { + console.error("[slack/events] Process link shared error:", err); + }); + } + + if (event.type === "entity_details_requested") { + processEntityDetails({ + event, + teamId: team_id, + eventId: event_id, + }).catch((err: unknown) => { + console.error("[slack/events] Process entity details error:", err); + }); + } + } + + // Slack requires 200 within 3s regardless of event type + return new Response("ok", { status: 200 }); +} diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts new file mode 100644 index 00000000000..111971d8f96 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts @@ -0,0 +1,2 @@ +export type { SlackAgentResult } from "./run-agent"; +export { runSlackAgent } from "./run-agent"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts new file mode 100644 index 00000000000..69e88f53d4e --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts @@ -0,0 +1,71 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { createInMemoryMcpClient } from "@superset/mcp/in-memory"; + +interface McpTool { + name: string; + description?: string; + inputSchema: unknown; +} + +// Uses InMemoryTransport — no HTTP, no forgeable headers. +export async function createSupersetMcpClient({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}): Promise<{ client: Client; cleanup: () => Promise }> { + return createInMemoryMcpClient({ organizationId, userId }); +} + +export async function createSlackMcpClient({ + token, + teamId, +}: { + token: string; + teamId: string; +}): Promise { + const transport = new StdioClientTransport({ + command: "npx", + args: ["-y", "@modelcontextprotocol/server-slack"], + env: { + ...process.env, + SLACK_BOT_TOKEN: token, + SLACK_TEAM_ID: teamId, + }, + }); + + const client = new Client({ + name: "slack-agent-slack", + version: "1.0.0", + }); + + await client.connect(transport); + return client; +} + +export function mcpToolToAnthropicTool( + tool: McpTool, + prefix: string, +): Anthropic.Tool { + return { + name: `${prefix}_${tool.name}`, + description: tool.description ?? "", + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + }; +} + +export function parseToolName(prefixedName: string): { + prefix: string; + toolName: string; +} { + const underscoreIndex = prefixedName.indexOf("_"); + if (underscoreIndex === -1) { + return { prefix: prefixedName, toolName: "" }; + } + const prefix = prefixedName.slice(0, underscoreIndex); + const toolName = prefixedName.slice(underscoreIndex + 1); + return { prefix, toolName }; +} diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts new file mode 100644 index 00000000000..958dee399d6 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts @@ -0,0 +1,373 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { WebClient } from "@slack/web-api"; + +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import type { AgentAction } from "../slack-blocks"; +import { + createSlackMcpClient, + createSupersetMcpClient, + mcpToolToAnthropicTool, + parseToolName, +} from "./mcp-clients"; + +async function fetchThreadContext({ + token, + channelId, + threadTs, + limit = 20, +}: { + token: string; + channelId: string; + threadTs: string; + limit?: number; +}): Promise { + try { + const slack = new WebClient(token); + const result = await slack.conversations.replies({ + channel: channelId, + ts: threadTs, + limit, + }); + + if (!result.messages || result.messages.length === 0) { + return ""; + } + + // Exclude the current mention (last message) + const messages = result.messages.slice(0, -1); + if (messages.length === 0) { + return ""; + } + + const formatted = messages + .map((msg) => `<${msg.user}>: ${msg.text}`) + .join("\n"); + + return `--- Thread Context (${messages.length} previous messages) ---\n${formatted}\n--- End Thread Context ---`; + } catch (error) { + console.warn("[slack-agent] Failed to fetch thread context:", error); + return ""; + } +} + +interface RunSlackAgentParams { + prompt: string; + channelId: string; + threadTs: string; + organizationId: string; + slackToken: string; + slackTeamId: string; +} + +export interface SlackAgentResult { + text: string; + actions: AgentAction[]; +} + +function getActionFromToolResult( + toolName: string, + // biome-ignore lint/suspicious/noExplicitAny: MCP result varies by tool + result: any, +): AgentAction | null { + const data = result.structuredContent ?? parseTextContent(result.content); + if (!data) return null; + + if (toolName === "create_task" && data.created) { + return { + type: "task_created", + tasks: data.created.map( + (t: { id: string; slug: string; title: string }) => ({ + id: t.id, + slug: t.slug, + title: t.title, + status: "Backlog", + }), + ), + }; + } + + if (toolName === "update_task" && data.updated) { + return { + type: "task_updated", + tasks: data.updated.map( + (t: { id: string; slug: string; title: string }) => ({ + id: t.id, + slug: t.slug, + title: t.title, + }), + ), + }; + } + + if (toolName === "create_workspace" && data.workspaceId) { + return { + type: "workspace_created", + workspaces: [ + { + id: data.workspaceId, + name: data.workspaceName, + branch: data.branch, + }, + ], + }; + } + + if ( + (toolName === "switch_workspace" || toolName === "navigate_to_workspace") && + data.workspaceId + ) { + return { + type: "workspace_switched", + workspaces: [ + { + id: data.workspaceId, + name: data.workspaceName, + branch: data.branch, + }, + ], + }; + } + + return null; +} + +// biome-ignore lint/suspicious/noExplicitAny: MCP content is loosely typed +function parseTextContent(content: any): Record | null { + try { + const contentItem = content?.[0]; + if ( + !contentItem || + typeof contentItem !== "object" || + !("text" in contentItem) + ) { + return null; + } + return JSON.parse(contentItem.text as string); + } catch { + return null; + } +} + +// Desktop-only tools that don't make sense in Slack context +const DENIED_SUPERSET_TOOLS = new Set([ + "navigate_to_workspace", + "switch_workspace", + "get_app_context", +]); + +const SYSTEM_PROMPT = `You are a helpful assistant in Slack for Superset, a task management application. + +You can: +- Create, update, search, and manage tasks using superset_* tools +- Read Slack messages and context using slack_* tools +- Help users understand conversations and create actionable items from discussions + +Guidelines: +- Be concise and clear (this is Slack, not email) +- When creating tasks, extract key details from the conversation +- Use Slack formatting: *bold*, _italic*, \`code\`, > quotes +- If an action fails, explain what went wrong and suggest alternatives + +Context gathering: +- If the user's request references something you don't have context for (a person, a conversation, a decision, etc.), USE THE SLACK TOOLS to find it +- Use slack_search_messages to find relevant discussions by keyword +- Use slack_get_channel_history to read recent channel messages +- Use slack_get_thread_replies to get full thread context +- Use slack_get_users to look up user details when names are mentioned +- Don't ask the user for context you can find yourself - be proactive + +Available tool prefixes: +- superset_*: Task management tools (create_task, list_tasks, update_task, etc.) +- slack_*: Slack tools (get_channel_history, search_messages, get_thread, etc.)`; + +export async function runSlackAgent( + params: RunSlackAgentParams, +): Promise { + const anthropic = new Anthropic(); + const actions: AgentAction[] = []; + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, params.organizationId), + eq(integrationConnections.provider, "slack"), + ), + columns: { connectedByUserId: true }, + }); + + if (!connection) { + throw new Error("Slack connection not found"); + } + + let supersetMcp: Client | null = null; + let cleanupSuperset: (() => Promise) | null = null; + let slackMcp: Client | null = null; + + try { + const [threadContext, supersetMcpResult, slackMcpResult] = + await Promise.all([ + fetchThreadContext({ + token: params.slackToken, + channelId: params.channelId, + threadTs: params.threadTs, + }), + createSupersetMcpClient({ + organizationId: params.organizationId, + userId: connection.connectedByUserId, + }), + createSlackMcpClient({ + token: params.slackToken, + teamId: params.slackTeamId, + }), + ]); + + supersetMcp = supersetMcpResult.client; + cleanupSuperset = supersetMcpResult.cleanup; + slackMcp = slackMcpResult; + + const [supersetToolsResult, slackToolsResult] = await Promise.all([ + supersetMcp.listTools(), + slackMcp.listTools(), + ]); + + const supersetTools = supersetToolsResult.tools + .map((t) => mcpToolToAnthropicTool(t, "superset")) + .filter((t) => !DENIED_SUPERSET_TOOLS.has(t.name)); + + const slackTools = slackToolsResult.tools.map((t) => + mcpToolToAnthropicTool(t, "slack"), + ); + + const tools: Anthropic.Tool[] = [...supersetTools, ...slackTools]; + + const contextualSystem = `${SYSTEM_PROMPT} + +Current context: +- Slack Channel: ${params.channelId} +- Thread: ${params.threadTs} +- Organization ID: ${params.organizationId}`; + + const userContent = threadContext + ? `${threadContext}\n\nCurrent message:\n${params.prompt}` + : params.prompt; + + const messages: Anthropic.MessageParam[] = [ + { + role: "user", + content: userContent, + }, + ]; + + let response = await anthropic.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 2048, + system: contextualSystem, + tools, + messages, + }); + + const MAX_TOOL_ITERATIONS = 10; + let iterations = 0; + + while ( + response.stop_reason === "tool_use" && + iterations < MAX_TOOL_ITERATIONS + ) { + iterations++; + const toolUseBlocks = response.content.filter( + (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", + ); + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + + for (const toolUse of toolUseBlocks) { + const { prefix, toolName } = parseToolName(toolUse.name); + const mcp = prefix === "superset" ? supersetMcp : slackMcp; + + if (!mcp) { + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: JSON.stringify({ + error: `Unknown tool prefix: ${prefix}`, + }), + is_error: true, + }); + continue; + } + + try { + const result = await mcp.callTool({ + name: toolName, + arguments: toolUse.input as Record, + }); + + const resultContent = JSON.stringify(result.content); + + if (prefix === "superset") { + const action = getActionFromToolResult(toolName, result); + if (action) { + actions.push(action); + } + } + + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: resultContent, + }); + } catch (error) { + console.error( + "[slack-agent] Tool execution error:", + toolUse.name, + error, + ); + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: JSON.stringify({ + error: + error instanceof Error + ? error.message + : "Tool execution failed", + }), + is_error: true, + }); + } + } + + messages.push({ role: "assistant", content: response.content }); + messages.push({ role: "user", content: toolResults }); + + response = await anthropic.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 2048, + system: contextualSystem, + tools, + messages, + }); + } + + const textBlock = response.content.find( + (b): b is Anthropic.TextBlock => b.type === "text", + ); + + return { + text: textBlock?.text ?? "Done!", + actions, + }; + } finally { + if (cleanupSuperset) { + try { + await cleanupSuperset(); + } catch {} + } + if (slackMcp) { + try { + await slackMcp.close(); + } catch {} + } + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts new file mode 100644 index 00000000000..b6476f41d92 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts @@ -0,0 +1,2 @@ +export type { AgentAction } from "./slack-blocks"; +export { formatActionsAsText } from "./slack-blocks"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts new file mode 100644 index 00000000000..5af0931c8d9 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts @@ -0,0 +1,60 @@ +import { env } from "@/env"; + +export interface TaskData { + id: string; + slug: string; + title: string; + description?: string | null; + status?: string; + priority?: string; +} + +export interface WorkspaceData { + id: string; + name: string; + branch?: string; +} + +export type AgentAction = + | { + type: "task_created" | "task_updated" | "task_deleted"; + tasks: TaskData[]; + } + | { + type: "workspace_created" | "workspace_switched"; + workspaces: WorkspaceData[]; + }; + +export function formatActionsAsText(actions: AgentAction[]): string { + const lines: string[] = []; + + for (const action of actions) { + if (action.type === "task_created") { + for (const task of action.tasks) { + const url = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; + lines.push(`Created task <${url}|${task.slug}>`); + } + } else if (action.type === "task_updated") { + for (const task of action.tasks) { + const url = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; + lines.push(`Updated task <${url}|${task.slug}>`); + } + } else if (action.type === "task_deleted") { + for (const task of action.tasks) { + lines.push(`Deleted task ${task.slug}`); + } + } else if (action.type === "workspace_created") { + for (const ws of action.workspaces) { + lines.push( + `Created workspace *${ws.name}*${ws.branch ? ` on branch \`${ws.branch}\`` : ""}`, + ); + } + } else if (action.type === "workspace_switched") { + for (const ws of action.workspaces) { + lines.push(`Switched to workspace *${ws.name}*`); + } + } + } + + return lines.join("\n"); +} diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts new file mode 100644 index 00000000000..b6222c22103 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts @@ -0,0 +1 @@ +export { createSlackClient } from "./slack-client"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-client/slack-client.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/slack-client.ts new file mode 100644 index 00000000000..5d189a2252f --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/slack-client.ts @@ -0,0 +1,5 @@ +import { WebClient } from "@slack/web-api"; + +export function createSlackClient(token: string): WebClient { + return new WebClient(token); +} diff --git a/apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts new file mode 100644 index 00000000000..f7d09d283a0 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts @@ -0,0 +1,5 @@ +export { + createTaskFlexpaneObject, + createTaskWorkObject, + parseTaskSlugFromUrl, +} from "./work-objects"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts new file mode 100644 index 00000000000..44dcf6f5805 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts @@ -0,0 +1,260 @@ +/** @see https://docs.slack.dev/messaging/work-objects/ */ + +import type { + EntityMetadata, + EntityType, + TaskEntityFields, +} from "@slack/types"; +import type { tasks } from "@superset/db/schema"; + +import { env } from "@/env"; + +const SUPERSET_PRODUCT_NAME = "Superset"; + +type TaskWithRelations = typeof tasks.$inferSelect & { + status?: { id: string; name: string } | null; + assignee?: { id: string; name: string | null; email: string } | null; +}; + +type TaskWithFullRelations = TaskWithRelations & { + creator?: { id: string; name: string | null; email: string } | null; + organization?: { id: string; name: string; slug: string } | null; +}; + +export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { + const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; + + const fields: TaskEntityFields = {}; + const displayOrder: string[] = []; + + fields.status = { + // Padded for spacing in Slack + value: task.status + ? `${task.status.name} ` + : "No status ", + }; + displayOrder.push("status"); + + if (task.assignee) { + fields.assignee = { + type: "slack#/types/user", + user: { + text: task.assignee.name ?? task.assignee.email, + email: task.assignee.email, + }, + }; + displayOrder.push("assignee"); + } + + return { + entity_type: "slack#/entities/task" as EntityType, + url: taskUrl, + app_unfurl_url: taskUrl, + external_ref: { + id: task.id, + type: "task", + }, + entity_payload: { + attributes: { + title: { + text: task.title, + }, + display_id: task.slug, + display_type: "Task", + product_name: SUPERSET_PRODUCT_NAME, + full_size_preview: { + is_supported: false, + }, + metadata_last_modified: Math.floor( + new Date(task.updatedAt).getTime() / 1000, + ), + }, + fields, + display_order: displayOrder, + }, + }; +} + +/** Includes all task fields for the expanded flexpane side panel. */ +export function createTaskFlexpaneObject( + task: TaskWithFullRelations, +): EntityMetadata { + const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; + + const fields: TaskEntityFields = {}; + const displayOrder: string[] = []; + + fields.description = { + value: task.description || "No description", + format: "markdown", + }; + displayOrder.push("description"); + + fields.status = { + value: task.status?.name ?? "No status", + }; + displayOrder.push("status"); + + if (task.assignee) { + fields.assignee = { + type: "slack#/types/user", + user: { + text: task.assignee.name ?? task.assignee.email, + email: task.assignee.email, + }, + }; + } else { + fields.assignee = { + type: "string", + value: "_Unassigned_", + format: "markdown", + }; + } + displayOrder.push("assignee"); + + const priorityValue = formatPriorityLabel(task.priority); + fields.priority = + task.priority === "none" + ? { value: "_None_", format: "markdown" } + : { value: priorityValue }; + displayOrder.push("priority"); + + const customFields: Array<{ + key: string; + label: string; + type: string; + value?: string | number; + format?: string; + user?: { text: string; email?: string }; + }> = []; + + const labels = task.labels as string[] | null; + customFields.push( + labels && labels.length > 0 + ? { + key: "labels", + label: "Labels", + type: "string", + value: labels.join(", "), + } + : { + key: "labels", + label: "Labels", + type: "string", + value: "_None_", + format: "markdown", + }, + ); + + customFields.push({ + key: "organization", + label: "Organization", + type: "string", + value: task.organization?.name ?? "—", + }); + + if (task.creator) { + customFields.push({ + key: "created_by", + label: "Created by", + type: "slack#/types/user", + user: { + text: task.creator.name ?? task.creator.email, + email: task.creator.email, + }, + }); + } else { + customFields.push({ + key: "created_by", + label: "Created by", + type: "string", + value: "_Unknown_", + format: "markdown", + }); + } + + customFields.push({ + key: "created", + label: "Created", + type: "slack#/types/timestamp", + value: Math.floor(new Date(task.createdAt).getTime() / 1000), + }); + + customFields.push({ + key: "updated", + label: "Updated", + type: "slack#/types/timestamp", + value: Math.floor(new Date(task.updatedAt).getTime() / 1000), + }); + + return { + entity_type: "slack#/entities/task" as EntityType, + url: taskUrl, + app_unfurl_url: taskUrl, + external_ref: { + id: task.id, + type: "task", + }, + entity_payload: { + attributes: { + title: { + text: task.title, + }, + display_id: task.slug, + display_type: "Task", + product_name: SUPERSET_PRODUCT_NAME, + full_size_preview: { + is_supported: false, + }, + metadata_last_modified: Math.floor( + new Date(task.updatedAt).getTime() / 1000, + ), + }, + fields, + custom_fields: customFields, + display_order: displayOrder, + actions: { + primary_actions: [ + { + text: "Open in Superset", + action_id: "open_task", + style: "primary", + url: taskUrl, + }, + ], + }, + }, + }; +} + +function formatPriorityLabel(priority: string): string { + const labels: Record = { + urgent: "Urgent", + high: "High", + medium: "Medium", + low: "Low", + none: "None", + }; + return labels[priority] ?? priority; +} + +/** + * Supports: + * - /tasks/my-task-slug (web app format) + * - /api/integrations/slack/tasks/my-task-slug (legacy API format) + */ +export function parseTaskSlugFromUrl(url: string): string | null { + try { + const parsed = new URL(url); + const webMatch = parsed.pathname.match(/^\/tasks\/([^/]+)/); + if (webMatch?.[1]) { + return webMatch[1]; + } + const apiMatch = parsed.pathname.match( + /^\/api\/integrations\/slack\/tasks\/([^/]+)/, + ); + return apiMatch?.[1] ?? null; + } catch { + return null; + } +} diff --git a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts new file mode 100644 index 00000000000..6d2505bbd26 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts @@ -0,0 +1,53 @@ +import { Receiver } from "@upstash/qstash"; +import { z } from "zod"; + +import { env } from "@/env"; +import { processSlackMention } from "../../events/process-mention"; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const payloadSchema = z.object({ + event: z.object({ + type: z.literal("app_mention"), + user: z.string(), + text: z.string(), + ts: z.string(), + channel: z.string(), + event_ts: z.string(), + thread_ts: z.string().optional(), + }), + teamId: z.string(), + eventId: z.string(), +}); + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const parsed = payloadSchema.safeParse(JSON.parse(body)); + if (!parsed.success) { + console.error("[slack/process-mention] Invalid payload:", parsed.error); + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + await processSlackMention(parsed.data); + + return Response.json({ success: true }); +} diff --git a/apps/api/src/app/api/integrations/slack/manifest.json b/apps/api/src/app/api/integrations/slack/manifest.json new file mode 100644 index 00000000000..f2ce76deba3 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/manifest.json @@ -0,0 +1,72 @@ +{ + "display_information": { + "name": "Superset", + "description": "Superset's supercharged AI coding assistant - Spawn cloud agents, plan tasks, do code reviews, and more", + "background_color": "#0a0a0a", + "long_description": "Supercharge your coding workflows with Superset, an AI Coding agent. Spin up a cloud sandbox, organize your work, open worktrees on your desktop app, or get fast answers. Just start a DM with Superset for instant access without leaving Slack.\r\nSuperset can make mistakes. Please take care with prompts." + }, + "features": { + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "Superset", + "always_online": true + }, + "unfurl_domains": ["app.superset.sh"], + "assistant_view": { + "assistant_description": "Superset's AI Coding Agent - spin up sandboxes, plan out work, run agents and more", + "suggested_prompts": [ + { + "title": "Create Task", + "message": "Help me create a task for learning about how the Superset Slack agent works" + } + ] + }, + "rich_previews": { + "is_active": true, + "entity_types": ["slack#/entities/task"] + } + }, + "oauth_config": { + "_comment_redirect_urls": "Update these URLs to match your NEXT_PUBLIC_API_URL (ngrok in dev, production URL in prod)", + "redirect_urls": [ + "https://api.superset.sh/api/integrations/slack/callback" + ], + "scopes": { + "bot": [ + "app_mentions:read", + "chat:write", + "reactions:write", + "channels:history", + "groups:history", + "im:history", + "im:read", + "im:write", + "mpim:history", + "users:read", + "assistant:write", + "links:read", + "links:write" + ] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://api.superset.sh/api/integrations/slack/events", + "bot_events": [ + "app_mention", + "message.im", + "assistant_thread_started", + "assistant_thread_context_changed", + "link_shared", + "entity_details_requested" + ] + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b83d7c741a8..915c906c8d2 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -24,6 +24,9 @@ export const env = createEnv({ GH_APP_ID: z.string().min(1), GH_APP_PRIVATE_KEY: z.string().min(1), GH_WEBHOOK_SECRET: z.string().min(1), + SLACK_CLIENT_ID: z.string().min(1), + SLACK_CLIENT_SECRET: z.string().min(1), + SLACK_SIGNING_SECRET: z.string().min(1), QSTASH_TOKEN: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), diff --git a/apps/api/src/lib/mcp/tools/devices/create-workspace/create-workspace.ts b/apps/api/src/lib/mcp/tools/devices/create-workspace/create-workspace.ts deleted file mode 100644 index bede438152a..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/create-workspace/create-workspace.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "create_workspace", - { - description: "Create a new git worktree workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - name: z - .string() - .optional() - .describe("Workspace name (auto-generated if not provided)"), - branchName: z - .string() - .optional() - .describe("Branch name (auto-generated if not provided)"), - baseBranch: z - .string() - .optional() - .describe("Branch to create from (defaults to main)"), - taskId: z - .string() - .optional() - .describe("Task ID to associate with workspace"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "create_workspace", - params: { - name: params.name, - branchName: params.branchName, - baseBranch: params.baseBranch, - taskId: params.taskId, - }, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/delete-workspace/delete-workspace.ts b/apps/api/src/lib/mcp/tools/devices/delete-workspace/delete-workspace.ts deleted file mode 100644 index 83cb6d6ed1c..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/delete-workspace/delete-workspace.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "delete_workspace", - { - description: "Delete a workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z.string().uuid().describe("Workspace ID to delete"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - const workspaceId = params.workspaceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "delete_workspace", - params: { workspaceId }, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/get-app-context/get-app-context.ts b/apps/api/src/lib/mcp/tools/devices/get-app-context/get-app-context.ts deleted file mode 100644 index ecc4c845646..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/get-app-context/get-app-context.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "get_app_context", - { - description: - "Get the current app context including pathname and active workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "get_app_context", - params: {}, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/list-devices/list-devices.ts b/apps/api/src/lib/mcp/tools/devices/list-devices/list-devices.ts deleted file mode 100644 index 0dc8c943cde..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/list-devices/list-devices.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { db } from "@superset/db/client"; -import { devicePresence, users } from "@superset/db/schema"; -import { and, desc, eq, gt } from "drizzle-orm"; -import { z } from "zod"; -import { DEVICE_ONLINE_THRESHOLD_MS, registerTool } from "../../utils"; - -export const register = registerTool( - "list_devices", - { - description: "List online devices in the organization", - inputSchema: { - includeOffline: z - .boolean() - .default(false) - .describe("Include recently offline devices"), - }, - }, - async (params, ctx) => { - const includeOffline = params.includeOffline as boolean; - const threshold = new Date(Date.now() - DEVICE_ONLINE_THRESHOLD_MS); - const offlineThreshold = new Date( - Date.now() - DEVICE_ONLINE_THRESHOLD_MS * 10, - ); - - const conditions = [eq(devicePresence.organizationId, ctx.organizationId)]; - - if (!includeOffline) { - conditions.push(gt(devicePresence.lastSeenAt, threshold)); - } else { - conditions.push(gt(devicePresence.lastSeenAt, offlineThreshold)); - } - - const devices = await db - .select({ - deviceId: devicePresence.deviceId, - deviceName: devicePresence.deviceName, - deviceType: devicePresence.deviceType, - lastSeenAt: devicePresence.lastSeenAt, - ownerId: devicePresence.userId, - ownerName: users.name, - ownerEmail: users.email, - }) - .from(devicePresence) - .innerJoin(users, eq(devicePresence.userId, users.id)) - .where(and(...conditions)) - .orderBy(desc(devicePresence.lastSeenAt)); - - const devicesWithStatus = devices.map((d) => ({ - ...d, - isOnline: d.lastSeenAt > threshold, - })); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ devices: devicesWithStatus }, null, 2), - }, - ], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/list-projects/list-projects.ts b/apps/api/src/lib/mcp/tools/devices/list-projects/list-projects.ts deleted file mode 100644 index 4ab56a1d60e..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/list-projects/list-projects.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "list_projects", - { - description: "List all projects on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "list_projects", - params: {}, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/list-workspaces/list-workspaces.ts b/apps/api/src/lib/mcp/tools/devices/list-workspaces/list-workspaces.ts deleted file mode 100644 index 17fc74f1696..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/list-workspaces/list-workspaces.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "list_workspaces", - { - description: "List all workspaces/worktrees on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "list_workspaces", - params: {}, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/navigate-to-workspace.ts b/apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/navigate-to-workspace.ts deleted file mode 100644 index 8f45c467f42..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/navigate-to-workspace.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "navigate_to_workspace", - { - description: "Navigate the desktop app to a specific workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z - .string() - .optional() - .describe("Workspace ID to navigate to"), - workspaceName: z - .string() - .optional() - .describe("Workspace name to navigate to"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - const workspaceId = params.workspaceId as string | undefined; - const workspaceName = params.workspaceName as string | undefined; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - if (!workspaceId && !workspaceName) { - return { - content: [ - { - type: "text", - text: "Error: Either workspaceId or workspaceName must be provided", - }, - ], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "navigate_to_workspace", - params: { workspaceId, workspaceName }, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/devices/switch-workspace/switch-workspace.ts b/apps/api/src/lib/mcp/tools/devices/switch-workspace/switch-workspace.ts deleted file mode 100644 index 4b765cc8b89..00000000000 --- a/apps/api/src/lib/mcp/tools/devices/switch-workspace/switch-workspace.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; - -export const register = registerTool( - "switch_workspace", - { - description: "Switch to a different workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z - .string() - .uuid() - .optional() - .describe("Workspace ID to switch to"), - workspaceName: z - .string() - .optional() - .describe("Workspace name to switch to"), - }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - const workspaceId = params.workspaceId as string | undefined; - const workspaceName = params.workspaceName as string | undefined; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - if (!workspaceId && !workspaceName) { - return { - content: [ - { - type: "text", - text: "Error: Either workspaceId or workspaceName must be provided", - }, - ], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "switch_workspace", - params: { workspaceId, workspaceName }, - }); - }, -); diff --git a/apps/api/src/lib/mcp/tools/index.ts b/apps/api/src/lib/mcp/tools/index.ts deleted file mode 100644 index e0536069d4f..00000000000 --- a/apps/api/src/lib/mcp/tools/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { register as registerCreateWorkspace } from "./devices/create-workspace"; -import { register as registerDeleteWorkspace } from "./devices/delete-workspace"; -import { register as registerGetAppContext } from "./devices/get-app-context"; -// Devices -import { register as registerListDevices } from "./devices/list-devices"; -import { register as registerListProjects } from "./devices/list-projects"; -import { register as registerListWorkspaces } from "./devices/list-workspaces"; -import { register as registerNavigateToWorkspace } from "./devices/navigate-to-workspace"; -import { register as registerSwitchWorkspace } from "./devices/switch-workspace"; -// Organizations -import { register as registerListMembers } from "./organizations/list-members"; -// Tasks -import { register as registerCreateTask } from "./tasks/create-task"; -import { register as registerDeleteTask } from "./tasks/delete-task"; -import { register as registerGetTask } from "./tasks/get-task"; -import { register as registerListTaskStatuses } from "./tasks/list-task-statuses"; -import { register as registerListTasks } from "./tasks/list-tasks"; -import { register as registerUpdateTask } from "./tasks/update-task"; - -export function registerTools(server: McpServer) { - // Tasks - registerCreateTask(server); - registerUpdateTask(server); - registerListTasks(server); - registerGetTask(server); - registerDeleteTask(server); - registerListTaskStatuses(server); - - // Organizations - registerListMembers(server); - - // Devices - registerListDevices(server); - registerListWorkspaces(server); - registerListProjects(server); - registerGetAppContext(server); - registerNavigateToWorkspace(server); - registerCreateWorkspace(server); - registerSwitchWorkspace(server); - registerDeleteWorkspace(server); -} diff --git a/apps/api/src/lib/mcp/tools/organizations/list-members/list-members.ts b/apps/api/src/lib/mcp/tools/organizations/list-members/list-members.ts deleted file mode 100644 index e3179513a33..00000000000 --- a/apps/api/src/lib/mcp/tools/organizations/list-members/list-members.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { db } from "@superset/db/client"; -import { members, users } from "@superset/db/schema"; -import { and, eq, ilike, or } from "drizzle-orm"; -import { z } from "zod"; -import { registerTool } from "../../utils"; - -export const register = registerTool( - "list_members", - { - description: "List members in the organization", - inputSchema: { - search: z.string().optional().describe("Search by name or email"), - limit: z.number().int().min(1).max(100).default(50), - }, - }, - async (params, ctx) => { - const limit = params.limit as number; - const search = params.search as string | undefined; - const conditions = [eq(members.organizationId, ctx.organizationId)]; - - let query = db - .select({ - id: users.id, - name: users.name, - email: users.email, - image: users.image, - role: members.role, - }) - .from(members) - .innerJoin(users, eq(members.userId, users.id)) - .where(and(...conditions)) - .limit(limit); - - if (search) { - query = db - .select({ - id: users.id, - name: users.name, - email: users.email, - image: users.image, - role: members.role, - }) - .from(members) - .innerJoin(users, eq(members.userId, users.id)) - .where( - and( - ...conditions, - or( - ilike(users.name, `%${search}%`), - ilike(users.email, `%${search}%`), - ), - ), - ) - .limit(limit); - } - - const membersList = await query; - - return { - content: [ - { - type: "text", - text: JSON.stringify({ members: membersList }, null, 2), - }, - ], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts b/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts deleted file mode 100644 index 2baab19d416..00000000000 --- a/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { db, dbWs } from "@superset/db/client"; -import { taskStatuses, tasks } from "@superset/db/schema"; -import { getCurrentTxid } from "@superset/db/utils"; -import { and, eq, ilike, or } from "drizzle-orm"; -import { z } from "zod"; -import { registerTool } from "../../utils"; - -const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; -type TaskPriority = (typeof PRIORITIES)[number]; - -function isPriority(value: unknown): value is TaskPriority { - return PRIORITIES.includes(value as TaskPriority); -} - -const taskInputSchema = z.object({ - title: z.string().min(1).describe("Task title"), - description: z.string().optional().describe("Task description (markdown)"), - priority: z - .enum(["urgent", "high", "medium", "low", "none"]) - .default("none") - .describe("Task priority"), - assigneeId: z.string().uuid().optional().describe("User ID to assign to"), - statusId: z - .string() - .uuid() - .optional() - .describe("Status ID (defaults to backlog)"), - labels: z.array(z.string()).optional().describe("Array of label strings"), - dueDate: z.string().datetime().optional().describe("Due date in ISO format"), - estimate: z - .number() - .int() - .positive() - .optional() - .describe("Estimate in points/hours"), -}); - -type TaskInput = z.infer; - -function generateBaseSlug(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 50); -} - -function generateUniqueSlug( - baseSlug: string, - existingSlugs: Set, -): string { - let slug = baseSlug; - if (existingSlugs.has(slug)) { - let counter = 1; - while (existingSlugs.has(slug)) { - slug = `${baseSlug}-${counter++}`; - } - } - return slug; -} - -export const register = registerTool( - "create_task", - { - description: "Create one or more tasks in the organization", - inputSchema: { - tasks: z - .array(taskInputSchema) - .min(1) - .max(25) - .describe("Array of tasks to create (1-25)"), - }, - }, - async (params, ctx) => { - const taskInputs = params.tasks as TaskInput[]; - - // Get default status if needed - let defaultStatusId: string | undefined; - const needsDefaultStatus = taskInputs.some((t) => !t.statusId); - - if (needsDefaultStatus) { - const [defaultStatus] = await db - .select({ id: taskStatuses.id }) - .from(taskStatuses) - .where( - and( - eq(taskStatuses.organizationId, ctx.organizationId), - eq(taskStatuses.type, "backlog"), - ), - ) - .orderBy(taskStatuses.position) - .limit(1); - - defaultStatusId = defaultStatus?.id; - if (!defaultStatusId) { - return { - content: [{ type: "text", text: "Error: No default status found" }], - isError: true, - }; - } - } - - // Collect all base slugs to query existing ones - const baseSlugs = taskInputs.map((t) => generateBaseSlug(t.title)); - const uniqueBaseSlugs = [...new Set(baseSlugs)]; - - // Query all potentially conflicting slugs in one go - const slugConditions = uniqueBaseSlugs.map((baseSlug) => - ilike(tasks.slug, `${baseSlug}%`), - ); - - const existingTasks = await db - .select({ slug: tasks.slug }) - .from(tasks) - .where( - and( - eq(tasks.organizationId, ctx.organizationId), - or(...slugConditions), - ), - ); - - // Track all used slugs (DB + in-batch) - const usedSlugs = new Set(existingTasks.map((t) => t.slug)); - - // Prepare all task values with unique slugs - const taskValues: Array<{ - slug: string; - title: string; - description: string | null; - priority: TaskPriority; - statusId: string; - organizationId: string; - creatorId: string; - assigneeId: string | null; - labels: string[]; - dueDate: Date | null; - estimate: number | null; - }> = []; - - for (const [i, input] of taskInputs.entries()) { - const baseSlug = baseSlugs[i] ?? ""; - const slug = generateUniqueSlug(baseSlug, usedSlugs); - - // Add to used slugs to prevent intra-batch collisions - usedSlugs.add(slug); - - const priority: TaskPriority = isPriority(input.priority) - ? input.priority - : "none"; - - // Use input.statusId if provided, otherwise fall back to defaultStatusId - // defaultStatusId is guaranteed to exist if any task needed it (checked earlier) - const statusId = input.statusId ?? (defaultStatusId as string); - - taskValues.push({ - slug, - title: input.title, - description: input.description ?? null, - priority, - statusId, - organizationId: ctx.organizationId, - creatorId: ctx.userId, - assigneeId: input.assigneeId ?? null, - labels: input.labels ?? [], - dueDate: input.dueDate ? new Date(input.dueDate) : null, - estimate: input.estimate ?? null, - }); - } - - // Insert all tasks in a single transaction - const result = await dbWs.transaction(async (tx) => { - const createdTasks = await tx - .insert(tasks) - .values(taskValues) - .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); - - const txid = await getCurrentTxid(tx); - return { createdTasks, txid }; - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - created: result.createdTasks, - txid: result.txid, - }, - null, - 2, - ), - }, - ], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/tasks/delete-task/delete-task.ts b/apps/api/src/lib/mcp/tools/tasks/delete-task/delete-task.ts deleted file mode 100644 index adb7c46d99f..00000000000 --- a/apps/api/src/lib/mcp/tools/tasks/delete-task/delete-task.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { db, dbWs } from "@superset/db/client"; -import { tasks } from "@superset/db/schema"; -import { getCurrentTxid } from "@superset/db/utils"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { registerTool } from "../../utils"; - -const UUID_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export const register = registerTool( - "delete_task", - { - description: "Soft delete one or more tasks", - inputSchema: { - taskIds: z - .array(z.string()) - .min(1) - .max(25) - .describe("Task IDs (uuid or slug) to delete (1-25)"), - }, - }, - async (params, ctx) => { - const taskIds = params.taskIds as string[]; - - // Resolve all taskIds to actual tasks - const resolvedTasks: { id: string; identifier: string }[] = []; - - for (const taskId of taskIds) { - const isUuid = UUID_REGEX.test(taskId); - - const [existingTask] = await db - .select({ id: tasks.id }) - .from(tasks) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!existingTask) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `Task not found: ${taskId}`, - failedAt: { index: resolvedTasks.length, taskId }, - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } - - resolvedTasks.push({ id: existingTask.id, identifier: taskId }); - } - - const taskIdsToDelete = resolvedTasks.map((t) => t.id); - const deletedAt = new Date(); - - const result = await dbWs.transaction(async (tx) => { - await tx - .update(tasks) - .set({ deletedAt }) - .where(inArray(tasks.id, taskIdsToDelete)); - - const txid = await getCurrentTxid(tx); - return { txid }; - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - deleted: taskIdsToDelete, - txid: result.txid, - }, - null, - 2, - ), - }, - ], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/tasks/get-task/get-task.ts b/apps/api/src/lib/mcp/tools/tasks/get-task/get-task.ts deleted file mode 100644 index e210432114b..00000000000 --- a/apps/api/src/lib/mcp/tools/tasks/get-task/get-task.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { db } from "@superset/db/client"; -import { taskStatuses, tasks, users } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import { z } from "zod"; -import { registerTool } from "../../utils"; - -export const register = registerTool( - "get_task", - { - description: "Get a single task by ID or slug", - inputSchema: { - taskId: z.string().describe("Task ID (uuid) or slug"), - }, - }, - async (params, ctx) => { - const taskId = params.taskId as string; - const isUuid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - taskId, - ); - - const assignee = alias(users, "assignee"); - const creator = alias(users, "creator"); - const status = alias(taskStatuses, "status"); - - const [task] = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusId: tasks.statusId, - statusName: status.name, - statusType: status.type, - statusColor: status.color, - assigneeId: tasks.assigneeId, - assigneeName: assignee.name, - assigneeEmail: assignee.email, - creatorId: tasks.creatorId, - creatorName: creator.name, - labels: tasks.labels, - dueDate: tasks.dueDate, - estimate: tasks.estimate, - branch: tasks.branch, - prUrl: tasks.prUrl, - createdAt: tasks.createdAt, - updatedAt: tasks.updatedAt, - }) - .from(tasks) - .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) - .leftJoin(creator, eq(tasks.creatorId, creator.id)) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!task) { - return { - content: [{ type: "text", text: "Error: Task not found" }], - isError: true, - }; - } - - return { - content: [{ type: "text", text: JSON.stringify(task, null, 2) }], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/tasks/list-task-statuses/list-task-statuses.ts b/apps/api/src/lib/mcp/tools/tasks/list-task-statuses/list-task-statuses.ts deleted file mode 100644 index 7b048fe559c..00000000000 --- a/apps/api/src/lib/mcp/tools/tasks/list-task-statuses/list-task-statuses.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { db } from "@superset/db/client"; -import { taskStatuses } from "@superset/db/schema"; -import { eq } from "drizzle-orm"; -import { registerTool } from "../../utils"; - -export const register = registerTool( - "list_task_statuses", - { - description: "List available task statuses for the organization", - inputSchema: {}, - }, - async (_params, ctx) => { - const statuses = await db - .select({ - id: taskStatuses.id, - name: taskStatuses.name, - color: taskStatuses.color, - type: taskStatuses.type, - position: taskStatuses.position, - }) - .from(taskStatuses) - .where(eq(taskStatuses.organizationId, ctx.organizationId)) - .orderBy(taskStatuses.position); - - return { - content: [{ type: "text", text: JSON.stringify({ statuses }, null, 2) }], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/tasks/list-tasks/list-tasks.ts b/apps/api/src/lib/mcp/tools/tasks/list-tasks/list-tasks.ts deleted file mode 100644 index 04065071ecf..00000000000 --- a/apps/api/src/lib/mcp/tools/tasks/list-tasks/list-tasks.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { db } from "@superset/db/client"; -import { taskStatuses, tasks, users } from "@superset/db/schema"; -import type { SQL } from "drizzle-orm"; -import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import { z } from "zod"; -import { registerTool } from "../../utils"; - -type TaskStatusType = - | "backlog" - | "unstarted" - | "started" - | "completed" - | "canceled"; - -const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; -type TaskPriority = (typeof PRIORITIES)[number]; - -function isPriority(value: unknown): value is TaskPriority { - return PRIORITIES.includes(value as TaskPriority); -} - -export const register = registerTool( - "list_tasks", - { - description: "List tasks with optional filters", - inputSchema: { - statusId: z.string().uuid().optional().describe("Filter by status ID"), - statusType: z - .enum(["backlog", "unstarted", "started", "completed", "canceled"]) - .optional() - .describe("Filter by status type"), - assigneeId: z.string().uuid().optional().describe("Filter by assignee"), - assignedToMe: z - .boolean() - .optional() - .describe("Filter to tasks assigned to current user"), - creatorId: z.string().uuid().optional().describe("Filter by creator"), - createdByMe: z - .boolean() - .optional() - .describe("Filter to tasks created by current user"), - priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), - labels: z - .array(z.string()) - .optional() - .describe("Filter by labels (tasks must have ALL specified labels)"), - search: z.string().optional().describe("Search in title/description"), - includeDeleted: z - .boolean() - .optional() - .describe("Include deleted tasks in results"), - limit: z.number().int().min(1).max(100).default(50), - offset: z.number().int().min(0).default(0), - }, - }, - async (params, ctx) => { - const statusId = params.statusId as string | undefined; - const statusType = params.statusType as TaskStatusType | undefined; - const assigneeId = params.assigneeId as string | undefined; - const assignedToMe = params.assignedToMe as boolean | undefined; - const creatorId = params.creatorId as string | undefined; - const createdByMe = params.createdByMe as boolean | undefined; - const priority = params.priority; - const labels = params.labels as string[] | undefined; - const search = params.search as string | undefined; - const includeDeleted = params.includeDeleted as boolean | undefined; - const limit = params.limit as number; - const offset = params.offset as number; - - const assignee = alias(users, "assignee"); - const creator = alias(users, "creator"); - const status = alias(taskStatuses, "status"); - - const conditions: SQL[] = [ - eq(tasks.organizationId, ctx.organizationId), - ]; - - if (!includeDeleted) { - conditions.push(isNull(tasks.deletedAt)); - } - - if (statusId) { - conditions.push(eq(tasks.statusId, statusId)); - } - - if (assigneeId) { - conditions.push(eq(tasks.assigneeId, assigneeId)); - } else if (assignedToMe) { - conditions.push(eq(tasks.assigneeId, ctx.userId)); - } - - if (creatorId) { - conditions.push(eq(tasks.creatorId, creatorId)); - } else if (createdByMe) { - conditions.push(eq(tasks.creatorId, ctx.userId)); - } - - if (isPriority(priority)) { - conditions.push(eq(tasks.priority, priority)); - } - - if (labels && labels.length > 0) { - // JSONB containment: tasks.labels must contain ALL specified labels - conditions.push(sql`${tasks.labels} @> ${JSON.stringify(labels)}::jsonb`); - } - - if (search) { - const searchCondition = or( - ilike(tasks.title, `%${search}%`), - ilike(tasks.description, `%${search}%`), - ); - if (searchCondition) { - conditions.push(searchCondition); - } - } - - if (statusType) { - const statusesOfType = await db - .select({ id: taskStatuses.id }) - .from(taskStatuses) - .where( - and( - eq(taskStatuses.organizationId, ctx.organizationId), - eq(taskStatuses.type, statusType), - ), - ); - const statusIds = statusesOfType.map((s) => s.id); - if (statusIds.length > 0) { - const statusCondition = or( - ...statusIds.map((id) => eq(tasks.statusId, id)), - ); - if (statusCondition) { - conditions.push(statusCondition); - } - } else { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { tasks: [], count: 0, hasMore: false }, - null, - 2, - ), - }, - ], - }; - } - } - - const tasksList = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusId: tasks.statusId, - statusName: status.name, - statusType: status.type, - assigneeId: tasks.assigneeId, - assigneeName: assignee.name, - creatorId: tasks.creatorId, - creatorName: creator.name, - labels: tasks.labels, - dueDate: tasks.dueDate, - estimate: tasks.estimate, - createdAt: tasks.createdAt, - deletedAt: tasks.deletedAt, - }) - .from(tasks) - .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) - .leftJoin(creator, eq(tasks.creatorId, creator.id)) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where(and(...conditions)) - .orderBy(desc(tasks.createdAt)) - .limit(limit) - .offset(offset); - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - tasks: tasksList, - count: tasksList.length, - hasMore: tasksList.length === limit, - }, - null, - 2, - ), - }, - ], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/tasks/update-task/update-task.ts b/apps/api/src/lib/mcp/tools/tasks/update-task/update-task.ts deleted file mode 100644 index f7772fb1306..00000000000 --- a/apps/api/src/lib/mcp/tools/tasks/update-task/update-task.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { db, dbWs } from "@superset/db/client"; -import { tasks } from "@superset/db/schema"; -import { getCurrentTxid } from "@superset/db/utils"; -import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { registerTool } from "../../utils"; - -const UUID_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -const updateSchema = z.object({ - taskId: z.string().describe("Task ID (uuid) or slug"), - title: z.string().min(1).optional().describe("New title"), - description: z.string().optional().describe("New description"), - priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), - assigneeId: z - .string() - .uuid() - .nullable() - .optional() - .describe("New assignee (null to unassign)"), - statusId: z.string().uuid().optional().describe("New status ID"), - labels: z.array(z.string()).optional().describe("Replace labels"), - dueDate: z - .string() - .datetime() - .nullable() - .optional() - .describe("New due date (null to clear)"), - estimate: z.number().int().positive().nullable().optional(), -}); - -type UpdateInput = z.infer; - -export const register = registerTool( - "update_task", - { - description: "Update one or more existing tasks", - inputSchema: { - updates: z - .array(updateSchema) - .min(1) - .max(25) - .describe("Array of task updates (1-25)"), - }, - }, - async (params, ctx) => { - const updates = params.updates as UpdateInput[]; - - // First pass: resolve all tasks and validate they exist - const resolvedUpdates: { - taskId: string; - updateData: Record; - }[] = []; - - for (const [i, update] of updates.entries()) { - const taskId = update.taskId; - const isUuid = UUID_REGEX.test(taskId); - - const [existingTask] = await db - .select({ id: tasks.id }) - .from(tasks) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!existingTask) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `Task not found: ${taskId}`, - failedAt: { index: i, taskId }, - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } - - // Build update data for this task - const updateData: Record = {}; - if (update.title !== undefined) updateData.title = update.title; - if (update.description !== undefined) - updateData.description = update.description; - if (update.priority !== undefined) updateData.priority = update.priority; - if (update.assigneeId !== undefined) - updateData.assigneeId = update.assigneeId; - if (update.statusId !== undefined) updateData.statusId = update.statusId; - if (update.labels !== undefined) updateData.labels = update.labels; - if (update.dueDate !== undefined) - updateData.dueDate = update.dueDate ? new Date(update.dueDate) : null; - if (update.estimate !== undefined) updateData.estimate = update.estimate; - - if (Object.keys(updateData).length === 0) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `No updatable fields provided for task: ${taskId}`, - failedAt: { index: i, taskId }, - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } - - resolvedUpdates.push({ taskId: existingTask.id, updateData }); - } - - // Second pass: apply all updates in a single transaction - const result = await dbWs.transaction(async (tx) => { - const updatedTasks: { id: string; slug: string; title: string }[] = []; - - for (const { taskId, updateData } of resolvedUpdates) { - const [task] = await tx - .update(tasks) - .set(updateData) - .where(eq(tasks.id, taskId)) - .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); - - if (task) { - updatedTasks.push(task); - } - } - - const txid = await getCurrentTxid(tx); - return { updatedTasks, txid }; - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - updated: result.updatedTasks, - txid: result.txid, - }, - null, - 2, - ), - }, - ], - }; - }, -); diff --git a/apps/api/src/lib/mcp/tools/utils/index.ts b/apps/api/src/lib/mcp/tools/utils/index.ts deleted file mode 100644 index 203be954c35..00000000000 --- a/apps/api/src/lib/mcp/tools/utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - DEVICE_ONLINE_THRESHOLD_MS, - executeOnDevice, -} from "./execute-on-device"; -export { registerTool } from "./register-tool"; diff --git a/apps/api/src/lib/mcp/tools/utils/register-tool.ts b/apps/api/src/lib/mcp/tools/utils/register-tool.ts deleted file mode 100644 index 2bbcd997383..00000000000 --- a/apps/api/src/lib/mcp/tools/utils/register-tool.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { - ServerNotification, - ServerRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import type { McpContext } from "../../auth"; - -type ToolResult = { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -}; - -type ToolExtra = RequestHandlerExtra & { - authInfo?: AuthInfo & { extra?: { mcpContext?: McpContext } }; -}; - -type InputSchema = Record; - -export function registerTool( - name: string, - config: { description: string; inputSchema: InputSchema }, - handler: ( - params: Record, - ctx: McpContext, - ) => Promise, -) { - return (server: McpServer) => { - server.tool( - name, - config.description, - config.inputSchema, - async (params: Record, extra: ToolExtra) => { - const ctx = extra.authInfo?.extra?.mcpContext; - if (!ctx) { - throw new Error("No MCP context available - authentication required"); - } - return handler(params, ctx); - }, - ); - }; -} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2536b6d8781..2a46c20f4e5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -40,14 +40,33 @@ if (process.defaultApp) { } async function processDeepLink(url: string): Promise { + console.log("[main] Processing deep link:", url); + + // Try auth deep link first (special handling) const authParams = parseAuthDeepLink(url); - if (!authParams) return; + if (authParams) { + const result = await handleAuthCallback(authParams); + if (result.success) { + focusMainWindow(); + } else { + console.error("[main] Auth deep link failed:", result.error); + } + return; + } - const result = await handleAuthCallback(authParams); - if (result.success) { - focusMainWindow(); - } else { - console.error("[main] Auth deep link failed:", result.error); + // For all other deep links, extract path and navigate in renderer + // e.g. superset://tasks/my-slug -> /tasks/my-slug + // e.g. superset://settings/integrations -> /settings/integrations + const path = `/${url.split("://")[1]}`; + + focusMainWindow(); + + // Navigate in renderer via loading the route directly + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + // Send navigation request to renderer + mainWindow.webContents.send("deep-link-navigate", path); } } diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index d7acfcf71ed..5eaaf0a7fcd 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -33,10 +33,18 @@ const unsubscribe = router.subscribe("onResolved", (event) => { }); }); +// Handle deep link navigation from main process +const handleDeepLink = (path: string) => { + console.log("[deep-link] Navigating to:", path); + router.navigate({ to: path }); +}; +window.ipcRenderer.on("deep-link-navigate", handleDeepLink); + // Clean up subscription on HMR if (import.meta.hot) { import.meta.hot.dispose(() => { unsubscribe(); + window.ipcRenderer.off("deep-link-navigate", handleDeepLink); }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx index a6276ec22b6..97d3be2ec13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx @@ -1,7 +1,7 @@ import { Button } from "@superset/ui/button"; import { ScrollArea } from "@superset/ui/scroll-area"; import { Separator } from "@superset/ui/separator"; -import { eq } from "@tanstack/db"; +import { eq, or } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; @@ -29,6 +29,7 @@ function TaskDetailPage() { useEscapeToNavigate("/tasks"); + // Support both UUID and slug lookups const { data: taskData } = useLiveQuery( (q) => q @@ -44,7 +45,7 @@ function TaskDetailPage() { status, assignee: assignee ?? null, })) - .where(({ tasks }) => eq(tasks.id, taskId)), + .where(({ tasks }) => or(eq(tasks.id, taskId), eq(tasks.slug, taskId))), [collections, taskId], ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx index 57cbbe32899..558e6b206d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx @@ -11,7 +11,7 @@ import { Skeleton } from "@superset/ui/skeleton"; import { useLiveQuery } from "@tanstack/react-db"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useEffect, useState } from "react"; -import { FaGithub } from "react-icons/fa"; +import { FaGithub, FaSlack } from "react-icons/fa"; import { HiCheckCircle, HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; import { SiLinear } from "react-icons/si"; import { @@ -97,11 +97,18 @@ export function IntegrationsSettings({ }, [fetchGithubInstallation]); const linearConnection = integrations?.find((i) => i.provider === "linear"); + const slackConnection = integrations?.find((i) => i.provider === "slack"); const isLinearConnected = !!linearConnection; + const isSlackConnected = !!slackConnection; const isGithubConnected = !!githubInstallation && !githubInstallation.suspended; const isLoading = isLoadingIntegrations || isLoadingGithub; + const showSlack = isItemVisible( + SETTING_ITEM_ID.INTEGRATIONS_SLACK, + visibleItems, + ); + const handleOpenWeb = (path: string) => { window.open(`${env.NEXT_PUBLIC_WEB_URL}${path}`, "_blank"); }; @@ -163,6 +170,22 @@ export function IntegrationsSettings({ } /> )} + + {showSlack && ( + } + isConnected={isSlackConnected} + connectedOrgName={slackConnection?.externalOrgName} + isLoading={isLoading} + onManage={() => + gateFeature(GATED_FEATURES.INTEGRATIONS, () => + handleOpenWeb("/integrations/slack"), + ) + } + /> + )}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index b09d5a905c0..568eeaa9f60 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -32,6 +32,7 @@ export const SETTING_ITEM_ID = { INTEGRATIONS_LINEAR: "integrations-linear", INTEGRATIONS_GITHUB: "integrations-github", + INTEGRATIONS_SLACK: "integrations-slack", BILLING_OVERVIEW: "billing-overview", BILLING_PLANS: "billing-plans", @@ -473,6 +474,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "git", ], }, + { + id: SETTING_ITEM_ID.INTEGRATIONS_SLACK, + section: "integrations", + title: "Slack", + description: "Manage tasks from Slack conversations", + keywords: [ + "integrations", + "slack", + "messages", + "conversations", + "tasks", + "chat", + "sync", + "connect", + "connected", + "communication", + ], + }, { id: SETTING_ITEM_ID.BILLING_OVERVIEW, section: "billing", diff --git a/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx b/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx index 4c21031b895..71c1ed89633 100644 --- a/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx +++ b/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx @@ -32,8 +32,8 @@ function parseSections(): SidebarSection[] { for (const node of pageTree.children) { if (node.type === "separator") { - // Parse separator format: "IconName Title" - const match = node.name.match(/^(\w+)\s+(.+)$/); + const name = String(node.name ?? ""); + const match = name.match(/^(\w+)\s+(.+)$/); if (match) { const [, iconName, title] = match; currentSection = { @@ -45,7 +45,7 @@ function parseSections(): SidebarSection[] { } } else if (node.type === "page" && currentSection) { currentSection.items.push({ - title: node.name, + title: String(node.name ?? ""), href: node.url, }); } diff --git a/apps/web/src/app/(dashboard)/integrations/page.tsx b/apps/web/src/app/(dashboard)/integrations/page.tsx index a920b03691d..c4971b34c1b 100644 --- a/apps/web/src/app/(dashboard)/integrations/page.tsx +++ b/apps/web/src/app/(dashboard)/integrations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FaGithub } from "react-icons/fa"; +import { FaGithub, FaSlack } from "react-icons/fa"; import { SiLinear } from "react-icons/si"; import { IntegrationCard, @@ -24,6 +24,14 @@ const integrations: IntegrationCardProps[] = [ accentColor: "#238636", icon: , }, + { + id: "slack", + name: "Slack", + description: "Connect Slack to manage tasks from conversations.", + category: "Communication", + accentColor: "#4A154B", + icon: , + }, ]; export default function IntegrationsPage() { diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx new file mode 100644 index 00000000000..5544538dd7d --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Unplug } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { env } from "@/env"; +import { useTRPC } from "@/trpc/react"; + +interface ConnectionControlsProps { + organizationId: string; + isConnected: boolean; +} + +export function ConnectionControls({ + organizationId, + isConnected, +}: ConnectionControlsProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const disconnectMutation = useMutation( + trpc.integration.slack.disconnect.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.integration.slack.getConnection.queryKey({ + organizationId, + }), + }); + router.refresh(); + }, + }), + ); + + const handleConnect = () => { + window.location.href = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/connect?organizationId=${organizationId}`; + }; + + const handleDisconnect = () => { + disconnectMutation.mutate({ organizationId }); + }; + + if (isConnected) { + return ( + + + + + + + Disconnect Slack? + + This will remove the connection between your organization and + Slack. You can reconnect at any time. + + + + Cancel + + Disconnect + + + + + ); + } + + return ; +} diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts new file mode 100644 index 00000000000..ca060e28397 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts @@ -0,0 +1 @@ +export { ConnectionControls } from "./ConnectionControls"; diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx new file mode 100644 index 00000000000..2933d235491 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { toast } from "@superset/ui/sonner"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +const ERROR_MESSAGES: Record = { + oauth_denied: "Authorization was denied. Please try again.", + missing_params: "Invalid OAuth response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: "Failed to connect to Slack. Please try again.", + slack_api_error: "Slack API error occurred. Please try again.", + unauthorized: "You are not authorized to perform this action.", +}; + +export function ErrorHandler() { + const searchParams = useSearchParams(); + + useEffect(() => { + const error = searchParams.get("error"); + if (error) { + toast.error(ERROR_MESSAGES[error] ?? "Something went wrong."); + window.history.replaceState({}, "", "/integrations/slack"); + } + }, [searchParams]); + + return null; +} diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts new file mode 100644 index 00000000000..c5e593d602a --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts @@ -0,0 +1 @@ +export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard)/integrations/slack/page.tsx b/apps/web/src/app/(dashboard)/integrations/slack/page.tsx new file mode 100644 index 00000000000..400213d8099 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/page.tsx @@ -0,0 +1,92 @@ +import { Badge } from "@superset/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { ArrowLeft, CheckCircle2 } from "lucide-react"; +import Link from "next/link"; +import { FaSlack } from "react-icons/fa"; +import { api } from "@/trpc/server"; +import { ConnectionControls } from "./components/ConnectionControls"; +import { ErrorHandler } from "./components/ErrorHandler"; + +export default async function SlackIntegrationPage() { + const trpc = await api(); + const organization = await trpc.user.myOrganization.query(); + + if (!organization) { + return ( +

+

+ You need to be part of an organization to use integrations. +

+
+ ); + } + + const connection = await trpc.integration.slack.getConnection.query({ + organizationId: organization.id, + }); + const isConnected = !!connection; + + return ( +
+ + + + + Back to Integrations + + +
+
+ +
+
+
+

Slack

+ {isConnected ? ( + + + Connected + + ) : ( + Not Connected + )} +
+

+ Connect Slack to manage tasks from conversations. Mention the bot in + any channel or send it a direct message to create and update tasks. +

+
+
+ + + + Connection + + Connect your Slack workspace to manage tasks from conversations. + + + + + {connection && ( +
+ Connected to{" "} + {connection.externalOrgName} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/tasks/[slug]/page.tsx b/apps/web/src/app/tasks/[slug]/page.tsx new file mode 100644 index 00000000000..0a2a8c9058a --- /dev/null +++ b/apps/web/src/app/tasks/[slug]/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; + +/** + * Deep link passthrough page for tasks. + * Attempts to open the Superset desktop app, falls back to dashboard. + */ +export default function TaskDeepLinkPage() { + const params = useParams<{ slug: string }>(); + const slug = params.slug; + const deepLink = `superset://tasks/${slug}`; + + useEffect(() => { + window.location.href = deepLink; + }, [deepLink]); + + return ( +
+
+ Superset +

+ Redirecting to desktop app... +

+ + Click here if not redirected + +
+
+ ); +} diff --git a/bun.lock b/bun.lock index 91b12187835..03d8b44a23f 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "name": "@superset/api", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.25.3", @@ -65,8 +66,11 @@ "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.36.0", + "@slack/types": "^2.19.0", + "@slack/web-api": "^7.13.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/mcp": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", @@ -565,6 +569,21 @@ "typescript": "^5.9.3", }, }, + "packages/mcp": { + "name": "@superset/mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", + "@superset/db": "workspace:*", + "drizzle-orm": "0.45.1", + "zod": "^4.3.5", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "typescript": "^5.9.3", + }, + }, "packages/scripts": { "name": "@superset/scripts", "version": "0.1.0", @@ -1761,6 +1780,12 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@slack/logger": ["@slack/logger@4.0.0", "", { "dependencies": { "@types/node": ">=18.0.0" } }, "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA=="], + + "@slack/types": ["@slack/types@2.19.0", "", {}, "sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA=="], + + "@slack/web-api": ["@slack/web-api@7.13.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/types": "^2.18.0", "@types/node": ">=18.0.0", "@types/retry": "0.12.0", "axios": "^1.11.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -1787,6 +1812,8 @@ "@superset/marketing": ["@superset/marketing@workspace:apps/marketing"], + "@superset/mcp": ["@superset/mcp@workspace:packages/mcp"], + "@superset/mobile": ["@superset/mobile@workspace:apps/mobile"], "@superset/scripts": ["@superset/scripts@workspace:packages/scripts"], @@ -2151,6 +2178,8 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], @@ -2379,6 +2408,8 @@ "ava": ["ava@5.3.1", "", { "dependencies": { "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", "arrgv": "^1.0.2", "arrify": "^3.0.0", "callsites": "^4.0.0", "cbor": "^8.1.0", "chalk": "^5.2.0", "chokidar": "^3.5.3", "chunkd": "^2.0.1", "ci-info": "^3.8.0", "ci-parallel-vars": "^1.0.1", "clean-yaml-object": "^0.1.0", "cli-truncate": "^3.1.0", "code-excerpt": "^4.0.0", "common-path-prefix": "^3.0.0", "concordance": "^5.0.4", "currently-unhandled": "^0.4.1", "debug": "^4.3.4", "emittery": "^1.0.1", "figures": "^5.0.0", "globby": "^13.1.4", "ignore-by-default": "^2.1.0", "indent-string": "^5.0.0", "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "matcher": "^5.0.0", "mem": "^9.0.2", "ms": "^2.1.3", "p-event": "^5.0.1", "p-map": "^5.5.0", "picomatch": "^2.3.1", "pkg-conf": "^4.0.0", "plur": "^5.1.0", "pretty-ms": "^8.0.0", "resolve-cwd": "^3.0.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.0.1", "supertap": "^3.0.1", "temp-dir": "^3.0.0", "write-file-atomic": "^5.0.1", "yargs": "^17.7.2" }, "peerDependencies": { "@ava/typescript": "*" }, "optionalPeers": ["@ava/typescript"], "bin": { "ava": "entrypoints/cli.mjs" } }, "sha512-Scv9a4gMOXB6+ni4toLuhAm9KYWEjsgBglJl+kMGI5+IVDt120CCDZyB5HNU9DjmLI2t4I0GbnxGLmmRfGTJGg=="], + "axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], @@ -3363,6 +3394,8 @@ "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], + "is-error": ["is-error@2.2.2", "", {}, "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -3391,7 +3424,7 @@ "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -3929,13 +3962,19 @@ "p-event": ["p-event@5.0.1", "", { "dependencies": { "p-timeout": "^5.0.2" } }, "sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@5.5.0", "", { "dependencies": { "aggregate-error": "^4.0.0" } }, "sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg=="], - "p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], @@ -5193,6 +5232,8 @@ "@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@slack/web-api/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -5349,6 +5390,8 @@ "estree-util-build-jsx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "expo/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -5381,6 +5424,8 @@ "fumadocs-ui/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "global-agent/matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -5509,6 +5554,8 @@ "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "p-event/p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], diff --git a/packages/db/drizzle/0015_slack_integration.sql b/packages/db/drizzle/0015_slack_integration.sql new file mode 100644 index 00000000000..6c1d713ed80 --- /dev/null +++ b/packages/db/drizzle/0015_slack_integration.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."integration_provider" ADD VALUE 'slack'; \ No newline at end of file diff --git a/packages/db/drizzle/0016_slack_integration.sql b/packages/db/drizzle/0016_slack_integration.sql new file mode 100644 index 00000000000..6c1d713ed80 --- /dev/null +++ b/packages/db/drizzle/0016_slack_integration.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."integration_provider" ADD VALUE 'slack'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0016_snapshot.json b/packages/db/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000000..206cd75ff77 --- /dev/null +++ b/packages/db/drizzle/meta/0016_snapshot.json @@ -0,0 +1,3335 @@ +{ + "id": "42baff60-2e3b-4f60-a33f-ad939180a08a", + "prevId": "a0800a2e-f4c6-40f4-8b6e-5f9095173faa", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_user_id_idx": { + "name": "apikeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikeys_user_id_users_id_fk": { + "name": "apikeys_user_id_users_id_fk", + "tableFrom": "apikeys", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_tokens_client_id_idx": { + "name": "oauth_access_tokens_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_user_id_idx": { + "name": "oauth_access_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_applications_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_applications_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_applications", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_access_token_unique": { + "name": "oauth_access_tokens_access_token_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token" + ] + }, + "oauth_access_tokens_refresh_token_unique": { + "name": "oauth_access_tokens_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_applications": { + "name": "oauth_applications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_applications_user_id_idx": { + "name": "oauth_applications_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_applications_user_id_users_id_fk": { + "name": "oauth_applications_user_id_users_id_fk", + "tableFrom": "oauth_applications", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_applications_client_id_unique": { + "name": "oauth_applications_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_consents_client_id_idx": { + "name": "oauth_consents_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consents_user_id_idx": { + "name": "oauth_consents_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consents_client_id_oauth_applications_client_id_fk": { + "name": "oauth_consents_client_id_oauth_applications_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_applications", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_organization_id_idx": { + "name": "repositories_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_slug_idx": { + "name": "repositories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_organization_id_organizations_id_fk": { + "name": "repositories_organization_id_organizations_id_fk", + "tableFrom": "repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_org_slug_unique": { + "name": "repositories_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_repository_id_idx": { + "name": "tasks_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_repository_id_repositories_id_fk": { + "name": "tasks_repository_id_repositories_id_fk", + "tableFrom": "tasks", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "claimed", + "executing", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 76a8fa86889..af21da71c45 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1769719998551, "tag": "0015_scope_tasks_slug_unique_to_org", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1769727642212, + "tag": "0016_slack_integration", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index d4ca978c868..b25bd5433c7 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -23,7 +23,7 @@ export const taskPriorityValues = [ export const taskPriorityEnum = z.enum(taskPriorityValues); export type TaskPriority = z.infer; -export const integrationProviderValues = ["linear", "github"] as const; +export const integrationProviderValues = ["linear", "github", "slack"] as const; export const integrationProviderEnum = z.enum(integrationProviderValues); export type IntegrationProvider = z.infer; diff --git a/packages/db/src/schema/types.ts b/packages/db/src/schema/types.ts index 9b90def9114..04df0ac7590 100644 --- a/packages/db/src/schema/types.ts +++ b/packages/db/src/schema/types.ts @@ -3,4 +3,8 @@ export type LinearConfig = { newTasksTeamId?: string; }; -export type IntegrationConfig = LinearConfig; +export type SlackConfig = { + provider: "slack"; +}; + +export type IntegrationConfig = LinearConfig | SlackConfig; diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000000..011d340f80a --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,34 @@ +{ + "name": "@superset/mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./auth": { + "types": "./src/auth.ts", + "default": "./src/auth.ts" + }, + "./in-memory": { + "types": "./src/in-memory.ts", + "default": "./src/in-memory.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", + "@superset/db": "workspace:*", + "drizzle-orm": "0.45.1", + "zod": "^4.3.5" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "typescript": "^5.9.3" + } +} diff --git a/apps/api/src/lib/mcp/auth.ts b/packages/mcp/src/auth.ts similarity index 100% rename from apps/api/src/lib/mcp/auth.ts rename to packages/mcp/src/auth.ts diff --git a/packages/mcp/src/in-memory.ts b/packages/mcp/src/in-memory.ts new file mode 100644 index 00000000000..8207abaf712 --- /dev/null +++ b/packages/mcp/src/in-memory.ts @@ -0,0 +1,44 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { McpContext } from "./auth"; +import { createMcpServer } from "./server"; + +export async function createInMemoryMcpClient({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}): Promise<{ client: Client; cleanup: () => Promise }> { + const server = createMcpServer(); + const [serverTransport, clientTransport] = + InMemoryTransport.createLinkedPair(); + + // Inject auth context into every message from client → server + const originalSend = clientTransport.send.bind(clientTransport); + clientTransport.send = (message, options) => + originalSend(message, { + ...options, + authInfo: { + token: "internal", + clientId: "slack-agent", + scopes: ["mcp:full"], + extra: { + mcpContext: { userId, organizationId } satisfies McpContext, + }, + }, + }); + + await server.connect(serverTransport); + + const client = new Client({ name: "superset-internal", version: "1.0.0" }); + await client.connect(clientTransport); + + return { + client, + cleanup: async () => { + await client.close(); + await server.close(); + }, + }; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000000..2ad1bfab449 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,3 @@ +export type { McpContext } from "./auth"; +export { createMcpServer } from "./server"; +export { registerTools } from "./tools"; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 00000000000..190dcf8fb96 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerTools } from "./tools"; + +export function createMcpServer(): McpServer { + const server = new McpServer( + { name: "superset", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + registerTools(server); + return server; +} diff --git a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts new file mode 100644 index 00000000000..ebfbf43fe5f --- /dev/null +++ b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts @@ -0,0 +1,54 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "create_workspace", + { + description: "Create a new git worktree workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + name: z + .string() + .optional() + .describe("Workspace name (auto-generated if not provided)"), + branchName: z + .string() + .optional() + .describe("Branch name (auto-generated if not provided)"), + baseBranch: z + .string() + .optional() + .describe("Branch to create from (defaults to main)"), + taskId: z + .string() + .optional() + .describe("Task ID to associate with workspace"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "create_workspace", + params: { + name: args.name, + branchName: args.branchName, + baseBranch: args.baseBranch, + taskId: args.taskId, + }, + }); + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/create-workspace/index.ts b/packages/mcp/src/tools/devices/create-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/create-workspace/index.ts rename to packages/mcp/src/tools/devices/create-workspace/index.ts diff --git a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts new file mode 100644 index 00000000000..cfe639a7f1e --- /dev/null +++ b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts @@ -0,0 +1,35 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "delete_workspace", + { + description: "Delete a workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + workspaceId: z.string().uuid().describe("Workspace ID to delete"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const workspaceId = args.workspaceId as string; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "delete_workspace", + params: { workspaceId }, + }); + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/delete-workspace/index.ts b/packages/mcp/src/tools/devices/delete-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/delete-workspace/index.ts rename to packages/mcp/src/tools/devices/delete-workspace/index.ts diff --git a/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts b/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts new file mode 100644 index 00000000000..ff6a430c911 --- /dev/null +++ b/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts @@ -0,0 +1,34 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "get_app_context", + { + description: + "Get the current app context including pathname and active workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "get_app_context", + params: {}, + }); + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/get-app-context/index.ts b/packages/mcp/src/tools/devices/get-app-context/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/get-app-context/index.ts rename to packages/mcp/src/tools/devices/get-app-context/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-devices/index.ts b/packages/mcp/src/tools/devices/list-devices/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-devices/index.ts rename to packages/mcp/src/tools/devices/list-devices/index.ts diff --git a/packages/mcp/src/tools/devices/list-devices/list-devices.ts b/packages/mcp/src/tools/devices/list-devices/list-devices.ts new file mode 100644 index 00000000000..55291b0683d --- /dev/null +++ b/packages/mcp/src/tools/devices/list-devices/list-devices.ts @@ -0,0 +1,83 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db } from "@superset/db/client"; +import { devicePresence, users } from "@superset/db/schema"; +import { and, desc, eq, gt } from "drizzle-orm"; +import { z } from "zod"; +import { DEVICE_ONLINE_THRESHOLD_MS, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "list_devices", + { + description: "List online devices in the organization", + inputSchema: { + includeOffline: z + .boolean() + .default(false) + .describe("Include recently offline devices"), + }, + outputSchema: { + devices: z.array( + z.object({ + deviceId: z.string(), + deviceName: z.string().nullable(), + deviceType: z.string(), + lastSeenAt: z.string(), + ownerId: z.string(), + ownerName: z.string().nullable(), + ownerEmail: z.string(), + isOnline: z.boolean(), + }), + ), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const includeOffline = args.includeOffline as boolean; + const threshold = new Date(Date.now() - DEVICE_ONLINE_THRESHOLD_MS); + const offlineThreshold = new Date( + Date.now() - DEVICE_ONLINE_THRESHOLD_MS * 10, + ); + + const conditions = [ + eq(devicePresence.organizationId, ctx.organizationId), + ]; + + if (!includeOffline) { + conditions.push(gt(devicePresence.lastSeenAt, threshold)); + } else { + conditions.push(gt(devicePresence.lastSeenAt, offlineThreshold)); + } + + const devices = await db + .select({ + deviceId: devicePresence.deviceId, + deviceName: devicePresence.deviceName, + deviceType: devicePresence.deviceType, + lastSeenAt: devicePresence.lastSeenAt, + ownerId: devicePresence.userId, + ownerName: users.name, + ownerEmail: users.email, + }) + .from(devicePresence) + .innerJoin(users, eq(devicePresence.userId, users.id)) + .where(and(...conditions)) + .orderBy(desc(devicePresence.lastSeenAt)); + + const devicesWithStatus = devices.map((d) => ({ + ...d, + isOnline: d.lastSeenAt > threshold, + })); + + return { + structuredContent: { devices: devicesWithStatus }, + content: [ + { + type: "text", + text: JSON.stringify({ devices: devicesWithStatus }, null, 2), + }, + ], + }; + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/list-projects/index.ts b/packages/mcp/src/tools/devices/list-projects/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-projects/index.ts rename to packages/mcp/src/tools/devices/list-projects/index.ts diff --git a/packages/mcp/src/tools/devices/list-projects/list-projects.ts b/packages/mcp/src/tools/devices/list-projects/list-projects.ts new file mode 100644 index 00000000000..fb85bdda652 --- /dev/null +++ b/packages/mcp/src/tools/devices/list-projects/list-projects.ts @@ -0,0 +1,33 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "list_projects", + { + description: "List all projects on a device", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "list_projects", + params: {}, + }); + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/list-workspaces/index.ts b/packages/mcp/src/tools/devices/list-workspaces/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-workspaces/index.ts rename to packages/mcp/src/tools/devices/list-workspaces/index.ts diff --git a/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts b/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts new file mode 100644 index 00000000000..4cd47f85711 --- /dev/null +++ b/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts @@ -0,0 +1,33 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "list_workspaces", + { + description: "List all workspaces/worktrees on a device", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "list_workspaces", + params: {}, + }); + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/index.ts b/packages/mcp/src/tools/devices/navigate-to-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/index.ts rename to packages/mcp/src/tools/devices/navigate-to-workspace/index.ts diff --git a/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts b/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts new file mode 100644 index 00000000000..468cdd502c4 --- /dev/null +++ b/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts @@ -0,0 +1,55 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "navigate_to_workspace", + { + description: "Navigate the desktop app to a specific workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + workspaceId: z + .string() + .optional() + .describe("Workspace ID to navigate to"), + workspaceName: z + .string() + .optional() + .describe("Workspace name to navigate to"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const workspaceId = args.workspaceId as string | undefined; + const workspaceName = args.workspaceName as string | undefined; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + if (!workspaceId && !workspaceName) { + return { + content: [ + { + type: "text", + text: "Error: Either workspaceId or workspaceName must be provided", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "navigate_to_workspace", + params: { workspaceId, workspaceName }, + }); + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/devices/switch-workspace/index.ts b/packages/mcp/src/tools/devices/switch-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/switch-workspace/index.ts rename to packages/mcp/src/tools/devices/switch-workspace/index.ts diff --git a/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts b/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts new file mode 100644 index 00000000000..8b12b95647f --- /dev/null +++ b/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts @@ -0,0 +1,56 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { executeOnDevice, getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "switch_workspace", + { + description: "Switch to a different workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + workspaceId: z + .string() + .uuid() + .optional() + .describe("Workspace ID to switch to"), + workspaceName: z + .string() + .optional() + .describe("Workspace name to switch to"), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const workspaceId = args.workspaceId as string | undefined; + const workspaceName = args.workspaceName as string | undefined; + + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } + + if (!workspaceId && !workspaceName) { + return { + content: [ + { + type: "text", + text: "Error: Either workspaceId or workspaceName must be provided", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId, + tool: "switch_workspace", + params: { workspaceId, workspaceName }, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 00000000000..baaaf65dc6d --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,40 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { register as createWorkspace } from "./devices/create-workspace"; +import { register as deleteWorkspace } from "./devices/delete-workspace"; +import { register as getAppContext } from "./devices/get-app-context"; +import { register as listDevices } from "./devices/list-devices"; +import { register as listProjects } from "./devices/list-projects"; +import { register as listWorkspaces } from "./devices/list-workspaces"; +import { register as navigateToWorkspace } from "./devices/navigate-to-workspace"; +import { register as switchWorkspace } from "./devices/switch-workspace"; +import { register as listMembers } from "./organizations/list-members"; +import { register as createTask } from "./tasks/create-task"; +import { register as deleteTask } from "./tasks/delete-task"; +import { register as getTask } from "./tasks/get-task"; +import { register as listTaskStatuses } from "./tasks/list-task-statuses"; +import { register as listTasks } from "./tasks/list-tasks"; +import { register as updateTask } from "./tasks/update-task"; + +const allTools = [ + createTask, + updateTask, + listTasks, + getTask, + deleteTask, + listTaskStatuses, + listMembers, + listDevices, + listWorkspaces, + listProjects, + getAppContext, + navigateToWorkspace, + createWorkspace, + switchWorkspace, + deleteWorkspace, +]; + +export function registerTools(server: McpServer) { + for (const register of allTools) { + register(server); + } +} diff --git a/apps/api/src/lib/mcp/tools/organizations/list-members/index.ts b/packages/mcp/src/tools/organizations/list-members/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/organizations/list-members/index.ts rename to packages/mcp/src/tools/organizations/list-members/index.ts diff --git a/packages/mcp/src/tools/organizations/list-members/list-members.ts b/packages/mcp/src/tools/organizations/list-members/list-members.ts new file mode 100644 index 00000000000..bc55c189442 --- /dev/null +++ b/packages/mcp/src/tools/organizations/list-members/list-members.ts @@ -0,0 +1,84 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db } from "@superset/db/client"; +import { members, users } from "@superset/db/schema"; +import { and, eq, ilike, or } from "drizzle-orm"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "list_members", + { + description: "List members in the organization", + inputSchema: { + search: z.string().optional().describe("Search by name or email"), + limit: z.number().int().min(1).max(100).default(50), + }, + outputSchema: { + members: z.array( + z.object({ + id: z.string(), + name: z.string().nullable(), + email: z.string(), + image: z.string().nullable(), + role: z.string(), + }), + ), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const limit = args.limit as number; + const search = args.search as string | undefined; + const conditions = [eq(members.organizationId, ctx.organizationId)]; + + let query = db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + role: members.role, + }) + .from(members) + .innerJoin(users, eq(members.userId, users.id)) + .where(and(...conditions)) + .limit(limit); + + if (search) { + query = db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + role: members.role, + }) + .from(members) + .innerJoin(users, eq(members.userId, users.id)) + .where( + and( + ...conditions, + or( + ilike(users.name, `%${search}%`), + ilike(users.email, `%${search}%`), + ), + ), + ) + .limit(limit); + } + + const membersList = await query; + + return { + structuredContent: { members: membersList }, + content: [ + { + type: "text", + text: JSON.stringify({ members: membersList }, null, 2), + }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/create-task/create-task.ts b/packages/mcp/src/tools/tasks/create-task/create-task.ts new file mode 100644 index 00000000000..b00c4288458 --- /dev/null +++ b/packages/mcp/src/tools/tasks/create-task/create-task.ts @@ -0,0 +1,190 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db, dbWs } from "@superset/db/client"; +import { taskStatuses, tasks } from "@superset/db/schema"; +import { and, eq, ilike, or } from "drizzle-orm"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; +type TaskPriority = (typeof PRIORITIES)[number]; + +function isPriority(value: unknown): value is TaskPriority { + return PRIORITIES.includes(value as TaskPriority); +} + +const taskInputSchema = z.object({ + title: z.string().min(1).describe("Task title"), + description: z.string().optional().describe("Task description (markdown)"), + priority: z + .enum(["urgent", "high", "medium", "low", "none"]) + .default("none") + .describe("Task priority"), + assigneeId: z.string().uuid().optional().describe("User ID to assign to"), + statusId: z + .string() + .uuid() + .optional() + .describe("Status ID (defaults to backlog)"), + labels: z.array(z.string()).optional().describe("Array of label strings"), + dueDate: z.string().datetime().optional().describe("Due date in ISO format"), + estimate: z + .number() + .int() + .positive() + .optional() + .describe("Estimate in points/hours"), +}); + +type TaskInput = z.infer; + +function generateBaseSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); +} + +function generateUniqueSlug( + baseSlug: string, + existingSlugs: Set, +): string { + let slug = baseSlug; + if (existingSlugs.has(slug)) { + let counter = 1; + while (existingSlugs.has(slug)) { + slug = `${baseSlug}-${counter++}`; + } + } + return slug; +} + +export function register(server: McpServer) { + server.registerTool( + "create_task", + { + description: "Create one or more tasks in the organization", + inputSchema: { + tasks: z + .array(taskInputSchema) + .min(1) + .max(25) + .describe("Array of tasks to create (1-25)"), + }, + outputSchema: { + created: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + }), + ), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const taskInputs = args.tasks as TaskInput[]; + + let defaultStatusId: string | undefined; + const needsDefaultStatus = taskInputs.some((t) => !t.statusId); + + if (needsDefaultStatus) { + const [defaultStatus] = await db + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, ctx.organizationId), + eq(taskStatuses.type, "backlog"), + ), + ) + .orderBy(taskStatuses.position) + .limit(1); + + defaultStatusId = defaultStatus?.id; + if (!defaultStatusId) { + return { + content: [{ type: "text", text: "Error: No default status found" }], + isError: true, + }; + } + } + + const baseSlugs = taskInputs.map((t) => generateBaseSlug(t.title)); + const uniqueBaseSlugs = [...new Set(baseSlugs)]; + + const slugConditions = uniqueBaseSlugs.map((baseSlug) => + ilike(tasks.slug, `${baseSlug}%`), + ); + + const existingTasks = await db + .select({ slug: tasks.slug }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, ctx.organizationId), + or(...slugConditions), + ), + ); + + const usedSlugs = new Set(existingTasks.map((t) => t.slug)); + + const taskValues: Array<{ + slug: string; + title: string; + description: string | null; + priority: TaskPriority; + statusId: string; + organizationId: string; + creatorId: string; + assigneeId: string | null; + labels: string[]; + dueDate: Date | null; + estimate: number | null; + }> = []; + + for (const [i, input] of taskInputs.entries()) { + const baseSlug = baseSlugs[i] ?? ""; + const slug = generateUniqueSlug(baseSlug, usedSlugs); + usedSlugs.add(slug); + + const priority: TaskPriority = isPriority(input.priority) + ? input.priority + : "none"; + + const statusId = input.statusId ?? (defaultStatusId as string); + + taskValues.push({ + slug, + title: input.title, + description: input.description ?? null, + priority, + statusId, + organizationId: ctx.organizationId, + creatorId: ctx.userId, + assigneeId: input.assigneeId ?? null, + labels: input.labels ?? [], + dueDate: input.dueDate ? new Date(input.dueDate) : null, + estimate: input.estimate ?? null, + }); + } + + const createdTasks = await dbWs.transaction(async (tx) => { + return tx + .insert(tasks) + .values(taskValues) + .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); + }); + + return { + structuredContent: { created: createdTasks }, + content: [ + { + type: "text", + text: JSON.stringify({ created: createdTasks }, null, 2), + }, + ], + }; + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/tasks/create-task/index.ts b/packages/mcp/src/tools/tasks/create-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/create-task/index.ts rename to packages/mcp/src/tools/tasks/create-task/index.ts diff --git a/packages/mcp/src/tools/tasks/delete-task/delete-task.ts b/packages/mcp/src/tools/tasks/delete-task/delete-task.ts new file mode 100644 index 00000000000..235bffdfea9 --- /dev/null +++ b/packages/mcp/src/tools/tasks/delete-task/delete-task.ts @@ -0,0 +1,79 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db, dbWs } from "@superset/db/client"; +import { tasks } from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "delete_task", + { + description: "Soft delete one or more tasks", + inputSchema: { + taskIds: z + .array(z.string()) + .min(1) + .max(25) + .describe("Task IDs (uuid or slug) to delete (1-25)"), + }, + outputSchema: { + deleted: z.array(z.string()), + txid: z.string(), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const taskIds = args.taskIds as string[]; + + const resolvedTasks: { id: string; identifier: string }[] = []; + + for (const taskId of taskIds) { + const isUuid = z.string().uuid().safeParse(taskId).success; + + const [existingTask] = await db + .select({ id: tasks.id }) + .from(tasks) + .where( + and( + isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!existingTask) { + return { + content: [ + { type: "text", text: `Error: Task not found: ${taskId}` }, + ], + isError: true, + }; + } + + resolvedTasks.push({ id: existingTask.id, identifier: taskId }); + } + + const taskIdsToDelete = resolvedTasks.map((t) => t.id); + const deletedAt = new Date(); + + const result = await dbWs.transaction(async (tx) => { + await tx + .update(tasks) + .set({ deletedAt }) + .where(inArray(tasks.id, taskIdsToDelete)); + + const txid = await getCurrentTxid(tx); + return { txid }; + }); + + const data = { deleted: taskIdsToDelete, txid: result.txid }; + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/tasks/delete-task/index.ts b/packages/mcp/src/tools/tasks/delete-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/delete-task/index.ts rename to packages/mcp/src/tools/tasks/delete-task/index.ts diff --git a/packages/mcp/src/tools/tasks/get-task/get-task.ts b/packages/mcp/src/tools/tasks/get-task/get-task.ts new file mode 100644 index 00000000000..16173d3bc7c --- /dev/null +++ b/packages/mcp/src/tools/tasks/get-task/get-task.ts @@ -0,0 +1,102 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db } from "@superset/db/client"; +import { taskStatuses, tasks, users } from "@superset/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "get_task", + { + description: "Get a single task by ID or slug", + inputSchema: { + taskId: z.string().describe("Task ID (uuid) or slug"), + }, + outputSchema: { + task: z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + description: z.string().nullable(), + priority: z.string(), + statusId: z.string().nullable(), + statusName: z.string().nullable(), + statusType: z.string().nullable(), + statusColor: z.string().nullable(), + assigneeId: z.string().nullable(), + assigneeName: z.string().nullable(), + assigneeEmail: z.string().nullable(), + creatorId: z.string().nullable(), + creatorName: z.string().nullable(), + labels: z.array(z.string()), + dueDate: z.string().nullable(), + estimate: z.number().nullable(), + branch: z.string().nullable(), + prUrl: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const taskId = args.taskId as string; + const isUuid = z.string().uuid().safeParse(taskId).success; + + const assignee = alias(users, "assignee"); + const creator = alias(users, "creator"); + const status = alias(taskStatuses, "status"); + + const [task] = await db + .select({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + description: tasks.description, + priority: tasks.priority, + statusId: tasks.statusId, + statusName: status.name, + statusType: status.type, + statusColor: status.color, + assigneeId: tasks.assigneeId, + assigneeName: assignee.name, + assigneeEmail: assignee.email, + creatorId: tasks.creatorId, + creatorName: creator.name, + labels: tasks.labels, + dueDate: tasks.dueDate, + estimate: tasks.estimate, + branch: tasks.branch, + prUrl: tasks.prUrl, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(creator, eq(tasks.creatorId, creator.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where( + and( + isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!task) { + return { + content: [{ type: "text", text: "Error: Task not found" }], + isError: true, + }; + } + + return { + structuredContent: { task }, + content: [{ type: "text", text: JSON.stringify({ task }, null, 2) }], + }; + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/tasks/get-task/index.ts b/packages/mcp/src/tools/tasks/get-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/get-task/index.ts rename to packages/mcp/src/tools/tasks/get-task/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/list-task-statuses/index.ts b/packages/mcp/src/tools/tasks/list-task-statuses/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/list-task-statuses/index.ts rename to packages/mcp/src/tools/tasks/list-task-statuses/index.ts diff --git a/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts b/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts new file mode 100644 index 00000000000..00103903472 --- /dev/null +++ b/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts @@ -0,0 +1,49 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db } from "@superset/db/client"; +import { taskStatuses } from "@superset/db/schema"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +export function register(server: McpServer) { + server.registerTool( + "list_task_statuses", + { + description: "List available task statuses for the organization", + inputSchema: {}, + outputSchema: { + statuses: z.array( + z.object({ + id: z.string(), + name: z.string(), + color: z.string(), + type: z.string(), + position: z.number(), + }), + ), + }, + }, + async (_args, extra) => { + const ctx = getMcpContext(extra); + + const statuses = await db + .select({ + id: taskStatuses.id, + name: taskStatuses.name, + color: taskStatuses.color, + type: taskStatuses.type, + position: taskStatuses.position, + }) + .from(taskStatuses) + .where(eq(taskStatuses.organizationId, ctx.organizationId)) + .orderBy(taskStatuses.position); + + return { + structuredContent: { statuses }, + content: [ + { type: "text", text: JSON.stringify({ statuses }, null, 2) }, + ], + }; + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/tasks/list-tasks/index.ts b/packages/mcp/src/tools/tasks/list-tasks/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/list-tasks/index.ts rename to packages/mcp/src/tools/tasks/list-tasks/index.ts diff --git a/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts b/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts new file mode 100644 index 00000000000..fb989d6786c --- /dev/null +++ b/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts @@ -0,0 +1,221 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db } from "@superset/db/client"; +import { taskStatuses, tasks, users } from "@superset/db/schema"; +import type { SQL } from "drizzle-orm"; +import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +type TaskStatusType = + | "backlog" + | "unstarted" + | "started" + | "completed" + | "canceled"; + +const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; +type TaskPriority = (typeof PRIORITIES)[number]; + +function isPriority(value: unknown): value is TaskPriority { + return PRIORITIES.includes(value as TaskPriority); +} + +export function register(server: McpServer) { + server.registerTool( + "list_tasks", + { + description: "List tasks with optional filters", + inputSchema: { + statusId: z.string().uuid().optional().describe("Filter by status ID"), + statusType: z + .enum(["backlog", "unstarted", "started", "completed", "canceled"]) + .optional() + .describe("Filter by status type"), + assigneeId: z.string().uuid().optional().describe("Filter by assignee"), + assignedToMe: z + .boolean() + .optional() + .describe("Filter to tasks assigned to current user"), + creatorId: z.string().uuid().optional().describe("Filter by creator"), + createdByMe: z + .boolean() + .optional() + .describe("Filter to tasks created by current user"), + priority: z + .enum(["urgent", "high", "medium", "low", "none"]) + .optional(), + labels: z + .array(z.string()) + .optional() + .describe("Filter by labels (tasks must have ALL specified labels)"), + search: z.string().optional().describe("Search in title/description"), + includeDeleted: z + .boolean() + .optional() + .describe("Include deleted tasks in results"), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), + }, + outputSchema: { + tasks: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + description: z.string().nullable(), + priority: z.string(), + statusId: z.string().nullable(), + statusName: z.string().nullable(), + statusType: z.string().nullable(), + assigneeId: z.string().nullable(), + assigneeName: z.string().nullable(), + creatorId: z.string().nullable(), + creatorName: z.string().nullable(), + labels: z.array(z.string()), + dueDate: z.string().nullable(), + estimate: z.number().nullable(), + createdAt: z.string(), + deletedAt: z.string().nullable(), + }), + ), + count: z.number(), + hasMore: z.boolean(), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const statusId = args.statusId as string | undefined; + const statusType = args.statusType as TaskStatusType | undefined; + const assigneeId = args.assigneeId as string | undefined; + const assignedToMe = args.assignedToMe as boolean | undefined; + const creatorId = args.creatorId as string | undefined; + const createdByMe = args.createdByMe as boolean | undefined; + const priority = args.priority; + const labels = args.labels as string[] | undefined; + const search = args.search as string | undefined; + const includeDeleted = args.includeDeleted as boolean | undefined; + const limit = args.limit as number; + const offset = args.offset as number; + + const assignee = alias(users, "assignee"); + const creator = alias(users, "creator"); + const status = alias(taskStatuses, "status"); + + const conditions: SQL[] = [ + eq(tasks.organizationId, ctx.organizationId), + ]; + + if (!includeDeleted) { + conditions.push(isNull(tasks.deletedAt)); + } + + if (statusId) { + conditions.push(eq(tasks.statusId, statusId)); + } + + if (assigneeId) { + conditions.push(eq(tasks.assigneeId, assigneeId)); + } else if (assignedToMe) { + conditions.push(eq(tasks.assigneeId, ctx.userId)); + } + + if (creatorId) { + conditions.push(eq(tasks.creatorId, creatorId)); + } else if (createdByMe) { + conditions.push(eq(tasks.creatorId, ctx.userId)); + } + + if (isPriority(priority)) { + conditions.push(eq(tasks.priority, priority)); + } + + if (labels && labels.length > 0) { + conditions.push( + sql`${tasks.labels} @> ${JSON.stringify(labels)}::jsonb`, + ); + } + + if (search) { + const searchCondition = or( + ilike(tasks.title, `%${search}%`), + ilike(tasks.description, `%${search}%`), + ); + if (searchCondition) { + conditions.push(searchCondition); + } + } + + if (statusType) { + const statusesOfType = await db + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, ctx.organizationId), + eq(taskStatuses.type, statusType), + ), + ); + const statusIds = statusesOfType.map((s) => s.id); + if (statusIds.length > 0) { + const statusCondition = or( + ...statusIds.map((id) => eq(tasks.statusId, id)), + ); + if (statusCondition) { + conditions.push(statusCondition); + } + } else { + const data = { tasks: [], count: 0, hasMore: false }; + return { + structuredContent: data, + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + }; + } + } + + const tasksList = await db + .select({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + description: tasks.description, + priority: tasks.priority, + statusId: tasks.statusId, + statusName: status.name, + statusType: status.type, + assigneeId: tasks.assigneeId, + assigneeName: assignee.name, + creatorId: tasks.creatorId, + creatorName: creator.name, + labels: tasks.labels, + dueDate: tasks.dueDate, + estimate: tasks.estimate, + createdAt: tasks.createdAt, + deletedAt: tasks.deletedAt, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(creator, eq(tasks.creatorId, creator.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where(and(...conditions)) + .orderBy(desc(tasks.createdAt)) + .limit(limit) + .offset(offset); + + const data = { + tasks: tasksList, + count: tasksList.length, + hasMore: tasksList.length === limit, + }; + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools/tasks/update-task/index.ts b/packages/mcp/src/tools/tasks/update-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/update-task/index.ts rename to packages/mcp/src/tools/tasks/update-task/index.ts diff --git a/packages/mcp/src/tools/tasks/update-task/update-task.ts b/packages/mcp/src/tools/tasks/update-task/update-task.ts new file mode 100644 index 00000000000..255d4ea967f --- /dev/null +++ b/packages/mcp/src/tools/tasks/update-task/update-task.ts @@ -0,0 +1,154 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db, dbWs } from "@superset/db/client"; +import { tasks } from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; + +const updateSchema = z.object({ + taskId: z.string().describe("Task ID (uuid) or slug"), + title: z.string().min(1).optional().describe("New title"), + description: z.string().optional().describe("New description"), + priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), + assigneeId: z + .string() + .uuid() + .nullable() + .optional() + .describe("New assignee (null to unassign)"), + statusId: z.string().uuid().optional().describe("New status ID"), + labels: z.array(z.string()).optional().describe("Replace labels"), + dueDate: z + .string() + .datetime() + .nullable() + .optional() + .describe("New due date (null to clear)"), + estimate: z.number().int().positive().nullable().optional(), +}); + +type UpdateInput = z.infer; + +export function register(server: McpServer) { + server.registerTool( + "update_task", + { + description: "Update one or more existing tasks", + inputSchema: { + updates: z + .array(updateSchema) + .min(1) + .max(25) + .describe("Array of task updates (1-25)"), + }, + outputSchema: { + updated: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + }), + ), + txid: z.string(), + }, + }, + async (args, extra) => { + const ctx = getMcpContext(extra); + const updates = args.updates as UpdateInput[]; + + const resolvedUpdates: { + taskId: string; + updateData: Record; + }[] = []; + + for (const [i, update] of updates.entries()) { + const taskId = update.taskId; + const isUuid = z.string().uuid().safeParse(taskId).success; + + const [existingTask] = await db + .select({ id: tasks.id }) + .from(tasks) + .where( + and( + isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!existingTask) { + return { + content: [ + { + type: "text", + text: `Error: Task not found: ${taskId} (index ${i})`, + }, + ], + isError: true, + }; + } + + const updateData: Record = {}; + if (update.title !== undefined) updateData.title = update.title; + if (update.description !== undefined) + updateData.description = update.description; + if (update.priority !== undefined) + updateData.priority = update.priority; + if (update.assigneeId !== undefined) + updateData.assigneeId = update.assigneeId; + if (update.statusId !== undefined) + updateData.statusId = update.statusId; + if (update.labels !== undefined) updateData.labels = update.labels; + if (update.dueDate !== undefined) + updateData.dueDate = update.dueDate ? new Date(update.dueDate) : null; + if (update.estimate !== undefined) + updateData.estimate = update.estimate; + + if (Object.keys(updateData).length === 0) { + return { + content: [ + { + type: "text", + text: `Error: No updatable fields provided for task: ${taskId} (index ${i})`, + }, + ], + isError: true, + }; + } + + resolvedUpdates.push({ taskId: existingTask.id, updateData }); + } + + const result = await dbWs.transaction(async (tx) => { + const updatedTasks: { id: string; slug: string; title: string }[] = []; + + for (const { taskId, updateData } of resolvedUpdates) { + const [task] = await tx + .update(tasks) + .set(updateData) + .where(eq(tasks.id, taskId)) + .returning({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + }); + + if (task) { + updatedTasks.push(task); + } + } + + const txid = await getCurrentTxid(tx); + return { updatedTasks, txid }; + }); + + const data = { updated: result.updatedTasks, txid: result.txid }; + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/utils/index.ts b/packages/mcp/src/tools/utils/index.ts new file mode 100644 index 00000000000..d314edf86f5 --- /dev/null +++ b/packages/mcp/src/tools/utils/index.ts @@ -0,0 +1,5 @@ +export { + DEVICE_ONLINE_THRESHOLD_MS, + executeOnDevice, + getMcpContext, +} from "./utils"; diff --git a/apps/api/src/lib/mcp/tools/utils/execute-on-device.ts b/packages/mcp/src/tools/utils/utils.ts similarity index 77% rename from apps/api/src/lib/mcp/tools/utils/execute-on-device.ts rename to packages/mcp/src/tools/utils/utils.ts index 227346b074e..5578a2641a9 100644 --- a/apps/api/src/lib/mcp/tools/utils/execute-on-device.ts +++ b/packages/mcp/src/tools/utils/utils.ts @@ -1,8 +1,32 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + ServerNotification, + ServerRequest, +} from "@modelcontextprotocol/sdk/types.js"; import { db } from "@superset/db/client"; import { agentCommands, devicePresence } from "@superset/db/schema"; import { and, eq, gt, inArray } from "drizzle-orm"; import type { McpContext } from "../../auth"; +// --- Auth context --- + +type ToolExtra = RequestHandlerExtra & { + authInfo?: AuthInfo & { extra?: { mcpContext?: McpContext } }; +}; + +export function getMcpContext( + extra: RequestHandlerExtra, +): McpContext { + const ctx = (extra as ToolExtra).authInfo?.extra?.mcpContext; + if (!ctx) { + throw new Error("No MCP context available - authentication required"); + } + return ctx; +} + +// --- Device execution --- + export const DEVICE_ONLINE_THRESHOLD_MS = 60_000; const POLL_INTERVAL_MS = 500; const DEFAULT_TIMEOUT_MS = 30_000; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 00000000000..525620cf0a6 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts index 55bcd8cf122..6d4456a432c 100644 --- a/packages/trpc/src/router/integration/github/github.ts +++ b/packages/trpc/src/router/integration/github/github.ts @@ -11,7 +11,7 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../../env"; import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; +import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; const qstash = new Client({ token: env.QSTASH_TOKEN }); diff --git a/packages/trpc/src/router/integration/integration.ts b/packages/trpc/src/router/integration/integration.ts index ab62013be8a..46f1ca428e0 100644 --- a/packages/trpc/src/router/integration/integration.ts +++ b/packages/trpc/src/router/integration/integration.ts @@ -1,28 +1,23 @@ import { db } from "@superset/db/client"; -import { integrationConnections, members } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../trpc"; import { githubRouter } from "./github"; import { linearRouter } from "./linear"; +import { slackRouter } from "./slack"; +import { verifyOrgMembership } from "./utils"; export const integrationRouter = { github: githubRouter, linear: linearRouter, + slack: slackRouter, list: protectedProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }) => { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, input.organizationId), - eq(members.userId, ctx.session.user.id), - ), - }); - if (!membership) { - throw new Error("Not a member of this organization"); - } + await verifyOrgMembership(ctx.session.user.id, input.organizationId); return db.query.integrationConnections.findMany({ where: eq(integrationConnections.organizationId, input.organizationId), diff --git a/packages/trpc/src/router/integration/linear/linear.ts b/packages/trpc/src/router/integration/linear/linear.ts index 47f43d48f4c..f4e8320795b 100644 --- a/packages/trpc/src/router/integration/linear/linear.ts +++ b/packages/trpc/src/router/integration/linear/linear.ts @@ -4,7 +4,8 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; -import { getLinearClient, verifyOrgAdmin, verifyOrgMembership } from "./utils"; +import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; +import { getLinearClient } from "./utils"; export const linearRouter = { getConnection: protectedProcedure diff --git a/packages/trpc/src/router/integration/linear/utils.ts b/packages/trpc/src/router/integration/linear/utils.ts index 6afe04071ed..034954c93e0 100644 --- a/packages/trpc/src/router/integration/linear/utils.ts +++ b/packages/trpc/src/router/integration/linear/utils.ts @@ -1,6 +1,6 @@ import { LinearClient } from "@linear/sdk"; import { db } from "@superset/db/client"; -import { integrationConnections, members } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; type Priority = "urgent" | "high" | "medium" | "low" | "none"; @@ -51,31 +51,3 @@ export async function getLinearClient( return new LinearClient({ accessToken: connection.accessToken }); } - -export async function verifyOrgMembership( - userId: string, - organizationId: string, -) { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), - }); - - if (!membership) { - throw new Error("Not a member of this organization"); - } - - return { membership }; -} - -export async function verifyOrgAdmin(userId: string, organizationId: string) { - const { membership } = await verifyOrgMembership(userId, organizationId); - - if (membership.role !== "admin" && membership.role !== "owner") { - throw new Error("Admin access required"); - } - - return { membership }; -} diff --git a/packages/trpc/src/router/integration/slack/index.ts b/packages/trpc/src/router/integration/slack/index.ts new file mode 100644 index 00000000000..cc59ff4cd82 --- /dev/null +++ b/packages/trpc/src/router/integration/slack/index.ts @@ -0,0 +1,2 @@ +export { slackRouter } from "./slack"; +export { getSlackConnection } from "./utils"; diff --git a/packages/trpc/src/router/integration/slack/slack.ts b/packages/trpc/src/router/integration/slack/slack.ts new file mode 100644 index 00000000000..156f4ec8e30 --- /dev/null +++ b/packages/trpc/src/router/integration/slack/slack.ts @@ -0,0 +1,57 @@ +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; + +export const slackRouter = { + getConnection: protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, "slack"), + ), + columns: { + id: true, + externalOrgName: true, + createdAt: true, + }, + }); + + if (!connection) return null; + + return { + id: connection.id, + externalOrgName: connection.externalOrgName, + connectedAt: connection.createdAt, + }; + }), + + disconnect: protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .mutation(async ({ ctx, input }) => { + await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + + const result = await db + .delete(integrationConnections) + .where( + and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, "slack"), + ), + ) + .returning({ id: integrationConnections.id }); + + if (result.length === 0) { + return { success: false, error: "No connection found" }; + } + + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/slack/utils.ts b/packages/trpc/src/router/integration/slack/utils.ts new file mode 100644 index 00000000000..bc4e2a3eec3 --- /dev/null +++ b/packages/trpc/src/router/integration/slack/utils.ts @@ -0,0 +1,14 @@ +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +export async function getSlackConnection(organizationId: string) { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "slack"), + ), + }); + + return connection ?? null; +} diff --git a/packages/trpc/src/router/integration/github/utils.ts b/packages/trpc/src/router/integration/utils.ts similarity index 100% rename from packages/trpc/src/router/integration/github/utils.ts rename to packages/trpc/src/router/integration/utils.ts