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 index f397ff8f140..b93b6376190 100644 --- 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 @@ -1,7 +1,9 @@ import type Anthropic from "@anthropic-ai/sdk"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { McpContext } from "@superset/mcp/auth"; -import { createInMemoryMcpClient } from "@superset/mcp/in-memory"; +import { createInMemoryMcpClient as createV1Client } from "@superset/mcp/in-memory"; +import { createInMemoryMcpClient as createV2Client } from "@superset/mcp-v2/in-memory"; +import { env } from "@/env"; import { posthog } from "@/lib/analytics"; interface McpTool { @@ -10,6 +12,8 @@ interface McpTool { inputSchema: unknown; } +const SLACK_CLIENT_LABEL = "slack-agent"; + // Uses InMemoryTransport — no HTTP, no forgeable headers. export async function createSupersetMcpClient({ organizationId, @@ -18,7 +22,7 @@ export async function createSupersetMcpClient({ organizationId: string; userId: string; }): Promise<{ client: Client; cleanup: () => Promise }> { - return createInMemoryMcpClient({ + return createV1Client({ organizationId, userId, source: "slack", @@ -36,6 +40,38 @@ export async function createSupersetMcpClient({ }); } +export async function createSupersetMcpV2Client({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}): Promise<{ client: Client; cleanup: () => Promise }> { + return createV2Client({ + organizationId, + userId, + clientLabel: SLACK_CLIENT_LABEL, + relayUrl: env.RELAY_URL, + onToolCall: (event) => { + posthog.capture({ + distinctId: event.userId, + event: "mcp_tool_called", + properties: { + tool: event.toolName, + organization_id: event.organizationId, + auth_source: event.source, + client_label: event.clientLabel, + duration_ms: event.durationMs, + success: event.success, + error_message: event.errorMessage, + mcp_server: "superset-v2", + }, + groups: { organization: event.organizationId }, + }); + }, + }); +} + export function mcpToolToAnthropicTool( tool: McpTool, prefix: string, 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 index 86467136eef..296dd7118e3 100644 --- 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 @@ -1,12 +1,15 @@ import Anthropic from "@anthropic-ai/sdk"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { WebClient } from "@slack/web-api"; +import { FEATURE_FLAGS } from "@superset/shared/constants"; import { env } from "@/env"; +import { posthog } from "@/lib/analytics"; import { DEFAULT_SLACK_MODEL } from "../../../constants"; import type { AgentAction } from "../slack-blocks"; import type { SlackImageAsset } from "../slack-image-assets"; import { createSupersetMcpClient, + createSupersetMcpV2Client, mcpToolToAnthropicTool, parseToolName, } from "./mcp-clients"; @@ -159,6 +162,7 @@ function getActionFromToolResult( const data = result.structuredContent ?? parseTextContent(result.content); if (!data) return null; + // v1 tool names — kept while the feature flag rollout is incomplete. if (toolName === "create_task" && data.created) { return { type: "task_created", @@ -225,6 +229,38 @@ function getActionFromToolResult( }; } + // v2 tool names. Wire format differs: tasks_create/update return + // `{ task, txid }`, tasks_delete returns `{ txid }` only (no task info + // — we skip surfacing that as an action), workspaces_create returns + // `{ workspace: { id, name, branch }, ... }`. + if (toolName === "tasks_create" && data.task) { + const t = data.task as { id: string; slug: string; title: string }; + return { + type: "task_created", + tasks: [{ id: t.id, slug: t.slug, title: t.title, status: "Backlog" }], + }; + } + + if (toolName === "tasks_update" && data.task) { + const t = data.task as { id: string; slug: string; title: string }; + return { + type: "task_updated", + tasks: [{ id: t.id, slug: t.slug, title: t.title }], + }; + } + + if (toolName === "workspaces_create" && data.workspace) { + const w = data.workspace as { + id: string; + name: string; + branch: string; + }; + return { + type: "workspace_created", + workspaces: [{ id: w.id, name: w.name, branch: w.branch }], + }; + } + return null; } @@ -262,6 +298,7 @@ function stripServerToolBlocks( } const TOOL_PROGRESS_STATUS: Record = { + // v1 create_task: "Creating task...", update_task: "Updating task...", delete_task: "Deleting task...", @@ -270,11 +307,29 @@ const TOOL_PROGRESS_STATUS: Record = { create_workspace: "Creating workspace...", list_workspaces: "Fetching workspaces...", list_projects: "Fetching projects...", + // v2 + tasks_create: "Creating task...", + tasks_update: "Updating task...", + tasks_delete: "Deleting task...", + tasks_list: "Searching tasks...", + tasks_get: "Fetching task details...", + workspaces_create: "Creating workspace...", + workspaces_list: "Fetching workspaces...", + workspaces_delete: "Deleting workspace...", + projects_list: "Fetching projects...", + hosts_list: "Fetching hosts...", + organization_members_list: "Fetching members...", + tasks_statuses_list: "Fetching task statuses...", + automations_list: "Fetching automations...", + automations_run: "Running automation...", + agents_list: "Fetching agents...", + agents_run: "Launching agent...", + // Server-side slack_get_channel_history: "Reading channel history...", }; -// Tools excluded from Slack agent context -const DENIED_SUPERSET_TOOLS = new Set([ +// v1 tools excluded from the Slack agent's tool list (preloaded as context). +const DENIED_SUPERSET_TOOLS_V1 = new Set([ "switch_workspace", "get_app_context", "list_members", @@ -282,6 +337,13 @@ const DENIED_SUPERSET_TOOLS = new Set([ "list_devices", ]); +// v2 tools excluded for the same reason. +const DENIED_SUPERSET_TOOLS_V2 = new Set([ + "organization_members_list", + "tasks_statuses_list", + "hosts_list", +]); + const SLACK_GET_CHANNEL_HISTORY_TOOL: Anthropic.Tool = { name: "slack_get_channel_history", description: @@ -361,17 +423,28 @@ Context gathering: async function fetchAgentContext({ mcpClient, userId, + useV2, }: { mcpClient: Client; userId: string; + useV2: boolean; }): Promise { - const [membersResult, statusesResult, devicesResult] = await Promise.all([ - mcpClient.callTool({ name: "list_members", arguments: {} }), - mcpClient.callTool({ name: "list_task_statuses", arguments: {} }), - mcpClient.callTool({ - name: "list_devices", - arguments: {}, - }), + const toolNames = useV2 + ? { + members: "organization_members_list", + statuses: "tasks_statuses_list", + hosts: "hosts_list", + } + : { + members: "list_members", + statuses: "list_task_statuses", + hosts: "list_devices", + }; + + const [membersResult, statusesResult, hostsResult] = await Promise.all([ + mcpClient.callTool({ name: toolNames.members, arguments: {} }), + mcpClient.callTool({ name: toolNames.statuses, arguments: {} }), + mcpClient.callTool({ name: toolNames.hosts, arguments: {} }), ]); const sections: string[] = []; @@ -403,20 +476,33 @@ async function fetchAgentContext({ sections.push(`Task statuses:\n${lines.join("\n")}`); } - const devicesData = devicesResult.structuredContent as { - devices: { - deviceId: string; - deviceName: string | null; - ownerName: string | null; - ownerEmail: string; - }[]; - } | null; - if (devicesData?.devices?.length) { - const lines = devicesData.devices.map( - (d) => - `- ${d.deviceName ?? "Unknown"} (id: ${d.deviceId}, owner: ${d.ownerName ?? d.ownerEmail})`, - ); - sections.push(`Devices:\n${lines.join("\n")}`); + if (useV2) { + // v2 hosts_list returns a bare array, which the SDK wraps as `{ result }`. + const hostsData = hostsResult.structuredContent as { + result: { id: string; name: string; online: boolean }[]; + } | null; + if (hostsData?.result?.length) { + const lines = hostsData.result.map( + (h) => `- ${h.name} (id: ${h.id}, online: ${h.online ? "yes" : "no"})`, + ); + sections.push(`Hosts:\n${lines.join("\n")}`); + } + } else { + const devicesData = hostsResult.structuredContent as { + devices: { + deviceId: string; + deviceName: string | null; + ownerName: string | null; + ownerEmail: string; + }[]; + } | null; + if (devicesData?.devices?.length) { + const lines = devicesData.devices.map( + (d) => + `- ${d.deviceName ?? "Unknown"} (id: ${d.deviceId}, owner: ${d.ownerName ?? d.ownerEmail})`, + ); + sections.push(`Devices:\n${lines.join("\n")}`); + } } return sections.join("\n\n"); @@ -464,6 +550,15 @@ export async function runSlackAgent( const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); const actions: AgentAction[] = []; + let useV2 = false; + try { + useV2 = Boolean( + await posthog.getFeatureFlag(FEATURE_FLAGS.SLACK_MCP_V2, params.userId), + ); + } catch (error) { + console.warn("[slack-agent] Failed to load mcp-v2 flag:", error); + } + let supersetMcp: Client | null = null; let cleanupSuperset: (() => Promise) | null = null; @@ -474,10 +569,15 @@ export async function runSlackAgent( channelId: params.channelId, threadTs: params.threadTs, }), - createSupersetMcpClient({ - organizationId: params.organizationId, - userId: params.userId, - }), + useV2 + ? createSupersetMcpV2Client({ + organizationId: params.organizationId, + userId: params.userId, + }) + : createSupersetMcpClient({ + organizationId: params.organizationId, + userId: params.userId, + }), ]); supersetMcp = supersetMcpResult.client; @@ -488,11 +588,15 @@ export async function runSlackAgent( fetchAgentContext({ mcpClient: supersetMcp, userId: params.userId, + useV2, }), ]); + const deniedTools = useV2 + ? DENIED_SUPERSET_TOOLS_V2 + : DENIED_SUPERSET_TOOLS_V1; const supersetTools = supersetToolsResult.tools - .filter((t) => !DENIED_SUPERSET_TOOLS.has(t.name)) + .filter((t) => !deniedTools.has(t.name)) .map((t) => mcpToolToAnthropicTool(t, "superset")); const tools: Anthropic.Messages.ToolUnion[] = [ diff --git a/packages/cli/src/commands/organization/members/list/command.ts b/packages/cli/src/commands/organization/members/list/command.ts new file mode 100644 index 00000000000..93d961c0f49 --- /dev/null +++ b/packages/cli/src/commands/organization/members/list/command.ts @@ -0,0 +1,22 @@ +import { number, string, table } from "@superset/cli-framework"; +import { command } from "../../../../lib/command"; + +export default command({ + description: "List members of the active organization", + options: { + search: string().alias("s").desc("Search by name or email"), + limit: number().default(50).desc("Max results"), + }, + display: (data) => + table( + data as Record[], + ["name", "email", "role", "id"], + ["NAME", "EMAIL", "ROLE", "ID"], + ), + run: async ({ ctx, options }) => { + return ctx.api.organization.members.list.query({ + search: options.search ?? undefined, + limit: options.limit, + }); + }, +}); diff --git a/packages/cli/src/commands/organization/members/meta.ts b/packages/cli/src/commands/organization/members/meta.ts new file mode 100644 index 00000000000..03fcf73da5e --- /dev/null +++ b/packages/cli/src/commands/organization/members/meta.ts @@ -0,0 +1,3 @@ +export default { + description: "Manage organization members", +}; diff --git a/packages/cli/src/commands/tasks/statuses/list/command.ts b/packages/cli/src/commands/tasks/statuses/list/command.ts new file mode 100644 index 00000000000..94a61b8d2fe --- /dev/null +++ b/packages/cli/src/commands/tasks/statuses/list/command.ts @@ -0,0 +1,15 @@ +import { table } from "@superset/cli-framework"; +import { command } from "../../../../lib/command"; + +export default command({ + description: "List task statuses in the active organization", + display: (data) => + table( + data as Record[], + ["name", "type", "position", "id"], + ["NAME", "TYPE", "POS", "ID"], + ), + run: async ({ ctx }) => { + return ctx.api.task.statuses.list.query(); + }, +}); diff --git a/packages/cli/src/commands/tasks/statuses/meta.ts b/packages/cli/src/commands/tasks/statuses/meta.ts new file mode 100644 index 00000000000..add49924627 --- /dev/null +++ b/packages/cli/src/commands/tasks/statuses/meta.ts @@ -0,0 +1,3 @@ +export default { + description: "Manage task statuses", +}; diff --git a/packages/mcp-v2/src/in-memory.ts b/packages/mcp-v2/src/in-memory.ts index 5f22f82e727..9b7b04e3ac0 100644 --- a/packages/mcp-v2/src/in-memory.ts +++ b/packages/mcp-v2/src/in-memory.ts @@ -5,6 +5,7 @@ import { db } from "@superset/db/client"; import { members, users } from "@superset/db/schema"; import { eq } from "drizzle-orm"; import type { McpContext } from "./auth"; +import type { McpToolCallEmitter } from "./define-tool"; import { createMcpServer } from "./server"; export interface InMemoryClientOptions { @@ -12,6 +13,7 @@ export interface InMemoryClientOptions { organizationId: string; clientLabel: string; relayUrl: string; + onToolCall?: McpToolCallEmitter; } /** @@ -28,6 +30,7 @@ export async function createInMemoryMcpClient({ organizationId, clientLabel, relayUrl, + onToolCall, }: InMemoryClientOptions): Promise<{ client: Client; cleanup: () => Promise; @@ -72,7 +75,7 @@ export async function createInMemoryMcpClient({ relayUrl, }; - const server = createMcpServer(); + const server = createMcpServer({ onToolCall }); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); diff --git a/packages/mcp-v2/src/tools/organization/members/list.ts b/packages/mcp-v2/src/tools/organization/members/list.ts new file mode 100644 index 00000000000..9d083bf019c --- /dev/null +++ b/packages/mcp-v2/src/tools/organization/members/list.ts @@ -0,0 +1,31 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createMcpCaller } from "../../../caller"; +import { defineTool } from "../../../define-tool"; + +export function register(server: McpServer): void { + defineTool(server, { + name: "organization_members_list", + description: + "List members of the active organization. Use this to look up a user's id by name or email before assigning a task or filtering by assignee.", + inputSchema: { + search: z + .string() + .min(1) + .nullish() + .describe("Free-text search on name or email."), + limit: z + .number() + .int() + .positive() + .max(100) + .default(50) + .describe("Max members to return. Default 50, max 100."), + }, + handler: async (input, ctx) => { + const caller = createMcpCaller(ctx); + const rows = await caller.organization.members.list(input ?? {}); + return { members: rows }; + }, + }); +} diff --git a/packages/mcp-v2/src/tools/register.ts b/packages/mcp-v2/src/tools/register.ts index 555d4c624b6..fbc81baec80 100644 --- a/packages/mcp-v2/src/tools/register.ts +++ b/packages/mcp-v2/src/tools/register.ts @@ -18,11 +18,13 @@ import * as automationsRun from "./automations/run"; import * as automationsSetPrompt from "./automations/set_prompt"; import * as automationsUpdate from "./automations/update"; import * as hostsList from "./hosts/list"; +import * as organizationMembersList from "./organization/members/list"; import * as projectsList from "./projects/list"; import * as tasksCreate from "./tasks/create"; import * as tasksDelete from "./tasks/delete"; import * as tasksGet from "./tasks/get"; import * as tasksList from "./tasks/list"; +import * as tasksStatusesList from "./tasks/statuses/list"; import * as tasksUpdate from "./tasks/update"; import * as workspacesCreate from "./workspaces/create"; import * as workspacesDelete from "./workspaces/delete"; @@ -34,6 +36,8 @@ const REGISTRARS = [ tasksCreate, tasksUpdate, tasksDelete, + tasksStatusesList, + organizationMembersList, automationsList, automationsGet, automationsGetPrompt, diff --git a/packages/mcp-v2/src/tools/tasks/statuses/list.ts b/packages/mcp-v2/src/tools/tasks/statuses/list.ts new file mode 100644 index 00000000000..ca31583a9a5 --- /dev/null +++ b/packages/mcp-v2/src/tools/tasks/statuses/list.ts @@ -0,0 +1,16 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpCaller } from "../../../caller"; +import { defineTool } from "../../../define-tool"; + +export function register(server: McpServer): void { + defineTool(server, { + name: "tasks_statuses_list", + description: + "List the available task statuses in the active organization. Use this to look up a status id by name (e.g. 'In Progress', 'Done') before creating or updating a task.", + handler: async (_input, ctx) => { + const caller = createMcpCaller(ctx); + const rows = await caller.task.statuses.list(); + return { statuses: rows }; + }, + }); +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 764e46a2ef9..868ec7ea02d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -73,6 +73,14 @@ import { } from "./resources/automations"; import { Host, HostListResponse, Hosts } from "./resources/hosts"; import * as API from "./resources/index"; +import { + Member, + MemberListParams, + MemberListResponse, + Members, + Organization, + OrganizationRole, +} from "./resources/organization"; import { Project, ProjectListResponse, Projects } from "./resources/projects"; import { Task, @@ -81,6 +89,9 @@ import { TaskListParams, TaskListResponse, Tasks, + TaskStatus, + TaskStatuses, + TaskStatusListResponse, TaskUpdateParams, } from "./resources/tasks"; import { @@ -1099,7 +1110,7 @@ export class Superset { static toFile = Uploads.toFile; - /** Tasks: create, list (with filters), retrieve, update, delete. */ + /** Tasks: create, list (with filters), retrieve, update, delete; nested `tasks.statuses.list`. */ tasks: API.Tasks = new API.Tasks(this); /** Workspaces (cloud records): list, delete. */ workspaces: API.Workspaces = new API.Workspaces(this); @@ -1111,6 +1122,8 @@ export class Superset { automations: API.Automations = new API.Automations(this); /** Agents (per-host terminal-agent rows): list, run. */ agents: API.Agents = new API.Agents(this); + /** Active-organization config: nested `organization.members.list`. */ + organization: API.Organization = new API.Organization(this); } Superset.Tasks = Tasks; @@ -1119,6 +1132,7 @@ Superset.Projects = Projects; Superset.Hosts = Hosts; Superset.Automations = Automations; Superset.Agents = Agents; +Superset.Organization = Organization; export declare namespace Superset { export type RequestOptions = Opts.RequestOptions; @@ -1131,6 +1145,18 @@ export declare namespace Superset { TaskCreateParams, TaskUpdateParams, TaskListParams, + TaskStatuses, + TaskStatus, + TaskStatusListResponse, + }; + + export { + Organization, + Members, + Member, + MemberListResponse, + MemberListParams, + OrganizationRole, }; export { diff --git a/packages/sdk/src/resources/index.ts b/packages/sdk/src/resources/index.ts index 4136553f5ac..aa5fc3df0b2 100644 --- a/packages/sdk/src/resources/index.ts +++ b/packages/sdk/src/resources/index.ts @@ -21,6 +21,14 @@ export { type AutomationUpdateParams, } from "./automations"; export { type Host, type HostListResponse, Hosts } from "./hosts"; +export { + type Member, + type MemberListParams, + type MemberListResponse, + Members, + Organization, + type OrganizationRole, +} from "./organization"; export { type Project, type ProjectListResponse, Projects } from "./projects"; export { type Task, @@ -29,6 +37,9 @@ export { type TaskListParams, type TaskListResponse, Tasks, + type TaskStatus, + type TaskStatusListResponse, + TaskStatuses, type TaskUpdateParams, } from "./tasks"; export { diff --git a/packages/sdk/src/resources/organization.ts b/packages/sdk/src/resources/organization.ts new file mode 100644 index 00000000000..1248b2f2268 --- /dev/null +++ b/packages/sdk/src/resources/organization.ts @@ -0,0 +1,55 @@ +import type { APIPromise } from "../core/api-promise"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; + +export class Members extends APIResource { + /** + * List members of the active organization. + * + * Mirrors `superset organization members list`. + */ + list( + query?: MemberListParams | null, + options?: RequestOptions, + ): APIPromise { + return this._client.query( + "organization.members.list", + query ?? undefined, + options, + ); + } +} + +export class Organization extends APIResource { + /** + * Member listing for the active organization. Member add/remove + * intentionally lives in the app — the programmatic API is read-only. + */ + members: Members = new Members(this._client); +} + +export type OrganizationRole = "member" | "admin" | "owner"; + +export interface Member { + id: string; + name: string | null; + email: string; + image: string | null; + role: OrganizationRole; +} + +export type MemberListResponse = Array; + +export interface MemberListParams { + search?: string | null; + limit?: number; +} + +export declare namespace Organization { + export type { + Member, + MemberListParams, + MemberListResponse, + OrganizationRole, + }; +} diff --git a/packages/sdk/src/resources/tasks.ts b/packages/sdk/src/resources/tasks.ts index cefff1938ec..7c78be30fcd 100644 --- a/packages/sdk/src/resources/tasks.ts +++ b/packages/sdk/src/resources/tasks.ts @@ -16,7 +16,27 @@ type ListRowWire = { statusName: string | null; }; +export class TaskStatuses extends APIResource { + /** + * List the task statuses configured for the active organization. + * + * Mirrors `superset tasks statuses list`. + */ + list(options?: RequestOptions): APIPromise { + return this._client.query( + "task.statuses.list", + undefined, + options, + ); + } +} + export class Tasks extends APIResource { + /** + * Status configuration (workflow states) for the active organization's tasks. + */ + statuses: TaskStatuses = new TaskStatuses(this._client); + /** * Create a task. * @@ -179,6 +199,16 @@ export interface TaskListParams { offset?: number; } +export interface TaskStatus { + id: string; + name: string; + color: string; + type: string; + position: number; +} + +export type TaskStatusListResponse = Array; + export declare namespace Tasks { export type { Task, @@ -187,5 +217,7 @@ export declare namespace Tasks { TaskCreateParams, TaskUpdateParams, TaskListParams, + TaskStatus, + TaskStatusListResponse, }; } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f8b931169fe..5e26d7e57c1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -78,4 +78,11 @@ export const FEATURE_FLAGS = { * UI visibility and staged rollout. */ AUTOMATIONS_ACCESS: "automations-access", + /** + * Routes the Slack agent to the v2 MCP server (`@superset/mcp-v2`) + * instead of v1 (`@superset/mcp`). Evaluated against the linking + * user's id (the Superset user behind the Slack mention) so it + * piggybacks on the existing All Access cohort. Off → v1. + */ + SLACK_MCP_V2: "slack-mcp-v2", } as const; diff --git a/packages/trpc/src/router/organization/members.ts b/packages/trpc/src/router/organization/members.ts new file mode 100644 index 00000000000..45185a74d2a --- /dev/null +++ b/packages/trpc/src/router/organization/members.ts @@ -0,0 +1,44 @@ +import { db } from "@superset/db/client"; +import { members, users } from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { and, eq, ilike, or } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; +import { requireActiveOrgMembership } from "../utils/active-org"; + +export const organizationMembersRouter = { + list: protectedProcedure + .input( + z + .object({ + search: z.string().min(1).nullish(), + limit: z.number().int().positive().max(100).default(50), + }) + .nullish(), + ) + .query(async ({ ctx, input }) => { + const organizationId = await requireActiveOrgMembership(ctx); + const conditions = [eq(members.organizationId, organizationId)]; + if (input?.search) { + const pattern = `%${input.search}%`; + const match = or( + ilike(users.name, pattern), + ilike(users.email, pattern), + ); + if (match) conditions.push(match); + } + + return 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(input?.limit ?? 50); + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/organization/organization.ts b/packages/trpc/src/router/organization/organization.ts index 4d7045b6f78..ceefc4ef57a 100644 --- a/packages/trpc/src/router/organization/organization.ts +++ b/packages/trpc/src/router/organization/organization.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { generateImagePathname, uploadImage } from "../../lib/upload"; import { jwtProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { verifyOrgAdmin } from "../integration/utils"; +import { organizationMembersRouter } from "./members"; async function getInvitationById(invitationId: string) { const invitation = await db.query.invitations.findFirst({ @@ -55,6 +56,8 @@ function verificationMatchesInvitation({ } export const organizationRouter = { + members: organizationMembersRouter, + getActive: protectedProcedure.query(async ({ ctx }) => { const orgId = ctx.activeOrganizationId; if (!orgId) return null; diff --git a/packages/trpc/src/router/task/statuses.ts b/packages/trpc/src/router/task/statuses.ts new file mode 100644 index 00000000000..40a34841b62 --- /dev/null +++ b/packages/trpc/src/router/task/statuses.ts @@ -0,0 +1,23 @@ +import { db } from "@superset/db/client"; +import { taskStatuses } from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { protectedProcedure } from "../../trpc"; +import { requireActiveOrgMembership } from "../utils/active-org"; + +export const taskStatusesRouter = { + list: protectedProcedure.query(async ({ ctx }) => { + const organizationId = await requireActiveOrgMembership(ctx); + return db + .select({ + id: taskStatuses.id, + name: taskStatuses.name, + color: taskStatuses.color, + type: taskStatuses.type, + position: taskStatuses.position, + }) + .from(taskStatuses) + .where(eq(taskStatuses.organizationId, organizationId)) + .orderBy(taskStatuses.position); + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/task/task.ts b/packages/trpc/src/router/task/task.ts index b3402afcd7c..09b750a9e90 100644 --- a/packages/trpc/src/router/task/task.ts +++ b/packages/trpc/src/router/task/task.ts @@ -23,6 +23,7 @@ import { taskListInputSchema, updateTaskSchema, } from "./schema"; +import { taskStatusesRouter } from "./statuses"; const TASK_SLUG_CONSTRAINT = "tasks_org_slug_unique"; const TASK_SLUG_RETRY_LIMIT = 5; @@ -265,6 +266,8 @@ async function createTask( } export const taskRouter = { + statuses: taskStatusesRouter, + /** * @deprecated Use `task.list` instead. Kept for one release cycle so the * shipped CLI on `main` keeps compiling against the new backend during