[SILO-345] [SILO-349] feat: Added intake issue creation with slack shortcuts (#3536)

* feat: added intake issue handler in create new work item

* feat: added intake service

* feat: modified issue modal and project selection to fit in intake creation

* feat: modified block action and view submission to handle intake issues

* feat: created intake linkback

* feat: added error for intake

* fix: intake issue not supporting states and labels

* fix: added link addition for issue creation
This commit is contained in:
Henit Chobisa
2025-07-14 17:23:06 +05:30
committed by GitHub
parent 731a37243e
commit b66f7cc403
14 changed files with 982 additions and 192 deletions

View File

@@ -1,10 +1,10 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
export default {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
"^@/(.*)$": "<rootDir>/src/$1",
},
testEnvironment: 'node',
testEnvironment: "node",
transform: {
'^.+\\.tsx?$': 'ts-jest'
}
"^.+\\.tsx?$": "ts-jest",
},
};

View File

@@ -95,6 +95,7 @@ export type SlackPrivateMetadata<T extends keyof EntityPayloadMapping = keyof En
export enum E_MESSAGE_ACTION_TYPES {
LINK_WORK_ITEM = "link_work_item",
CREATE_NEW_WORK_ITEM = "issue_shortcut",
CREATE_INTAKE_ISSUE = "create_intake_issue",
DISCONNECT_WORK_ITEM = "disconnect_work_item",
ISSUE_WEBLINK_SUBMISSION = "issue_weblink_submission",
ISSUE_COMMENT_SUBMISSION = "issue_comment_submission",

View File

@@ -0,0 +1,181 @@
import { ExIntakeIssue } from "@plane/sdk";
import { env } from "@/env";
import { formatTimestampToNaturalLanguage } from "../helpers/format-date";
// Intake issue statuses based on the Django model
export const INTAKE_STATUSES = [
{ id: -2, name: "Pending", emoji: "⏳" },
{ id: -1, name: "Rejected", emoji: "❌" },
{ id: 0, name: "Snoozed", emoji: "😴" },
{ id: 1, name: "Accepted", emoji: "✅" },
{ id: 2, name: "Duplicate", emoji: "🔄" },
];
export const createSlackIntakeLinkback = (workspaceSlug: string, issue: ExIntakeIssue, showLogo = false) => {
const { issue_detail } = issue;
// Get status info
const statusInfo = INTAKE_STATUSES.find((s) => s.id === issue.status) || INTAKE_STATUSES[0];
const blocks: any[] = [];
if (showLogo) {
blocks.push({
type: "context",
elements: [
{
type: "image",
image_url: "https://res.cloudinary.com/ddglxo0l3/image/upload/v1732200793/xljpcpmftawmjkv4x61s.png",
alt_text: "Plane",
},
{
type: "mrkdwn",
text: `*Plane Intake*`,
},
],
});
}
// Main title with link to issue (if we have the issue detail)
const titleText = issue_detail?.project
? `<${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue_detail.project}/intake|📥 ${issue_detail.name}>`
: `📥 *${issue_detail.name}*`;
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: titleText,
},
});
// Create context elements array with intake-specific information
const contextElements: any[] = [];
// Status information
contextElements.push({
type: "mrkdwn",
text: `*Status:* ${statusInfo.emoji} ${statusInfo.name}`,
});
// Source information
if (issue.source) {
let sourceText = `*Source:* ${issue.source}`;
if (issue.source_email) {
sourceText += ` (${issue.source_email})`;
}
contextElements.push({
type: "mrkdwn",
text: sourceText,
});
}
// External source if available
if (issue.external_source) {
contextElements.push({
type: "mrkdwn",
text: `*External Source:* ${issue.external_source}`,
});
}
// Project information (if available)
if (issue_detail?.project) {
contextElements.push({
type: "mrkdwn",
text: `📋 <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue_detail.project}/issues|Project>`,
});
}
// Add context block for intake-specific info
if (contextElements.length > 0) {
blocks.push({
type: "context",
elements: contextElements,
});
}
// Issue details context (state, priority, assignees, dates)
const issueContextElements: any[] = [];
// State and priority information
if (issue_detail?.state || issue_detail?.priority) {
// @ts-expect-error
const stateLabel = issue_detail?.state ? `*State:* ${issue_detail.state.name}` : "*State:* Not set";
const priorityLabel =
issue_detail?.priority && issue_detail.priority !== "none"
? `*Priority:* ${titleCaseWord(issue_detail.priority)}`
: "*Priority:* Not set";
issueContextElements.push({
type: "mrkdwn",
text: `${stateLabel} ${priorityLabel}`,
});
}
// Assignees information
if (issue_detail?.assignees && issue_detail.assignees.length > 0) {
const assigneeCount = issue_detail.assignees.length;
const assigneeText = assigneeCount === 1 ? "Assignee" : "Assignees";
issueContextElements.push({
type: "mrkdwn",
text: `*${assigneeText}:* ${assigneeCount} assigned`,
});
}
// Target date
if (issue_detail?.target_date) {
issueContextElements.push({
type: "plain_text",
text: `Target Date: ${formatTimestampToNaturalLanguage(issue_detail.target_date)}`,
});
}
// Created date
if (issue.created_at) {
issueContextElements.push({
type: "plain_text",
text: `Created: ${formatTimestampToNaturalLanguage(issue.created_at)}`,
});
}
// Snoozed until date (for snoozed status)
if (issue.status === 0 && issue.snoozed_till) {
issueContextElements.push({
type: "plain_text",
text: `Snoozed until: ${formatTimestampToNaturalLanguage(issue.snoozed_till)}`,
});
}
// Add issue context block if there are any elements
if (issueContextElements.length > 0) {
blocks.push({
type: "context",
elements: issueContextElements,
});
}
// Duplicate issue information (for duplicate status)
if (issue.status === 2 && issue.duplicate_to) {
blocks.push({
type: "context",
elements: [
{
type: "mrkdwn",
text: `🔄 *Duplicate of:* <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue_detail.project}/issues/${issue.duplicate_to}|View Original Issue>`,
},
],
});
}
blocks.push({
type: "divider",
});
return { blocks };
};
function titleCaseWord(word: string) {
if (!word) return word;
return word[0].toUpperCase() + word.substr(1).toLowerCase();
}

View File

@@ -21,15 +21,13 @@ export type IssueModalViewFull = {
blocks: IssueModalViewBlocks;
};
export type IssueModalViewBlocks = [
StaticSelectInputBlock,
PlainTextInputBlock,
RichTextInputBlock,
StaticSelectInputBlock,
StaticSelectInputBlock,
MultiExternalSelectInputBlock,
...(CheckboxInputBlock | undefined)[],
];
export type IssueModalViewBlocks = (
| StaticSelectInputBlock
| PlainTextInputBlock
| RichTextInputBlock
| MultiExternalSelectInputBlock
| CheckboxInputBlock
)[];
export const createIssueModalViewFull = (
{
@@ -46,34 +44,36 @@ export const createIssueModalViewFull = (
title?: string,
privateMetadata: string = "{}",
showThreadSync: boolean = true,
isWorkItem: boolean = true,
messageBlocks?: any[] // Add optional messageBlocks parameter
): IssueModalViewFull => ({
type: "modal",
callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM,
callback_id: isWorkItem ? E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM : E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE,
private_metadata: privateMetadata,
title: {
type: "plain_text",
text: "Create Issue",
text: isWorkItem ? "Create Work Item" : "Create Intake Issue",
emoji: true,
},
submit: {
type: "plain_text",
text: "Create Issue",
text: isWorkItem ? "Create Work Item" : "Create Intake Issue",
emoji: true,
},
close: {
type: "plain_text",
text: "Discard Issue",
text: "Discard",
emoji: true,
},
blocks: [
// For intake issues (non-work items), only show title, description, and priority
{
dispatch_action: true,
type: "input",
type: "input" as const,
element: {
type: "static_select",
type: "static_select" as const,
placeholder: {
type: "plain_text",
type: "plain_text" as const,
text: "Select a Project",
emoji: true,
},
@@ -82,74 +82,78 @@ export const createIssueModalViewFull = (
action_id: ACTIONS.PROJECT,
},
label: {
type: "plain_text",
type: "plain_text" as const,
text: "Project",
emoji: true,
},
},
{
type: "input",
type: "input" as const,
element: {
type: "plain_text_input",
type: "plain_text_input" as const,
placeholder: {
type: "plain_text",
type: "plain_text" as const,
text: "Issue Title",
},
action_id: ACTIONS.ISSUE_TITLE,
},
label: {
type: "plain_text",
type: "plain_text" as const,
text: "Title",
emoji: true,
},
},
{
type: "input",
type: "input" as const,
optional: true,
element: {
type: "rich_text_input",
type: "rich_text_input" as const,
action_id: ACTIONS.ISSUE_DESCRIPTION,
initial_value: {
type: "rich_text",
type: "rich_text" as const,
elements: extractRichTextElements(title, messageBlocks),
},
placeholder: {
type: "plain_text",
type: "plain_text" as const,
text: "Issue Description (Optional)",
},
},
label: {
type: "plain_text",
type: "plain_text" as const,
text: "Description",
emoji: true,
},
},
...(isWorkItem
? [
{
type: "input" as const,
optional: true,
element: {
type: "static_select" as const,
placeholder: {
type: "plain_text" as const,
text: "Select a State",
emoji: true,
},
options: stateOptions,
action_id: ACTIONS.ISSUE_STATE,
},
label: {
type: "plain_text" as const,
text: "State",
emoji: true,
},
},
]
: []),
{
type: "input",
type: "input" as const,
optional: true,
element: {
type: "static_select",
type: "static_select" as const,
placeholder: {
type: "plain_text",
text: "Select a State",
emoji: true,
},
options: stateOptions,
action_id: ACTIONS.ISSUE_STATE,
},
label: {
type: "plain_text",
text: "State",
emoji: true,
},
},
{
type: "input",
optional: true,
element: {
type: "static_select",
placeholder: {
type: "plain_text",
type: "plain_text" as const,
text: "Select a Priority (Optional)",
emoji: true,
},
@@ -157,32 +161,36 @@ export const createIssueModalViewFull = (
action_id: ACTIONS.ISSUE_PRIORITY,
},
label: {
type: "plain_text",
type: "plain_text" as const,
text: "Priority",
emoji: true,
},
},
{
type: "input",
optional: true,
element: {
type: "multi_external_select",
placeholder: {
type: "plain_text",
text: "Labels (Optional)",
emoji: true,
},
min_query_length: 3,
action_id: ACTIONS.ISSUE_LABELS,
initial_options: [],
},
label: {
type: "plain_text",
text: "Labels",
emoji: true,
},
},
...(showThreadSync
...(isWorkItem
? [
{
type: "input" as const,
optional: true,
element: {
type: "multi_external_select" as const,
placeholder: {
type: "plain_text" as const,
text: "Labels (Optional)",
emoji: true,
},
min_query_length: 3,
action_id: ACTIONS.ISSUE_LABELS,
initial_options: [],
},
label: {
type: "plain_text" as const,
text: "Labels",
emoji: true,
},
},
]
: []),
...(showThreadSync && isWorkItem
? [
{
type: "input" as const,

View File

@@ -5,22 +5,24 @@ import { E_MESSAGE_ACTION_TYPES, ShortcutActionPayload } from "../types/types";
export const createProjectSelectionModal = (
projects: Array<PlainTextOption>,
privateMetadata: ShortcutActionPayload,
type: EntityTypeValue = ENTITIES.SHORTCUT_PROJECT_SELECTION
type: EntityTypeValue = ENTITIES.SHORTCUT_PROJECT_SELECTION,
isWorkItem: boolean = true,
error: string = ""
) => ({
type: "modal",
callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM,
callback_id: isWorkItem ? E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM : E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE,
private_metadata: JSON.stringify({
entityType: type,
entityPayload: privateMetadata,
}),
title: {
type: "plain_text",
text: "Create Issue",
text: isWorkItem ? "Create Work Item" : "Create Intake Issue",
emoji: true,
},
close: {
type: "plain_text",
text: "Discard Issue",
text: "Discard",
emoji: true,
},
blocks: [
@@ -43,5 +45,18 @@ export const createProjectSelectionModal = (
emoji: true,
},
},
...(error
? [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `:warning: *Error:* ${error}`,
},
],
},
]
: []),
],
});

View File

@@ -2,16 +2,16 @@ import axios from "axios";
import { TBlockActionModalPayload, TBlockActionPayload } from "@plane/etl/slack";
import { fetchPlaneAssets } from "@/apps/slack/helpers/fetch-plane-data";
import { convertToSlackOption, convertToSlackOptions } from "@/apps/slack/helpers/slack-options";
import { createIssueModalViewFull } from "@/apps/slack/views";
import { createIssueModalViewFull, createProjectSelectionModal } from "@/apps/slack/views";
import { CONSTANTS } from "@/helpers/constants";
import { logger } from "@/logger";
import { getConnectionDetails } from "../../helpers/connection-details";
import { ACTIONS, ENTITIES, PLANE_PRIORITIES } from "../../helpers/constants";
import { E_MESSAGE_ACTION_TYPES, SlackPrivateMetadata, TSlackConnectionDetails } from "../../types/types";
import { getAccountConnectionBlocks } from "../../views/account-connection";
import { createCommentModal } from "../../views/create-comment-modal";
import { createWebLinkModal } from "../../views/create-weblink-modal";
import { createSlackLinkback } from "../../views/issue-linkback";
import { getAccountConnectionBlocks } from "../../views/account-connection";
const shouldSkipActions = (data: TBlockActionPayload) => {
const excludedActions = [E_MESSAGE_ACTION_TYPES.CONNECT_ACCOUNT];
@@ -247,6 +247,30 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload, details
typeof ENTITIES.SHORTCUT_PROJECT_SELECTION
>;
if (data.view.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE) {
const intakeEnabled = selectedProject.intake_view;
if (!intakeEnabled) {
const modal = createProjectSelectionModal(
convertToSlackOptions(projects.results),
{
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
message: {
text: metadata.entityPayload.message.text,
ts: metadata.entityPayload.message.ts,
},
channel: {
id: metadata.entityPayload.channel.id,
},
},
undefined,
false,
"Intake is not enabled for this project."
);
await slackService.updateModal(data.view.id, modal);
return;
}
}
if (
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION ||
metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION
@@ -260,7 +284,8 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload, details
},
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION ? metadata.entityPayload.message?.text : "",
JSON.stringify({ entityType: metadata.entityType, entityPayload: metadata.entityPayload }),
metadata.entityPayload.type !== ENTITIES.COMMAND_PROJECT_SELECTION
metadata.entityPayload.type !== ENTITIES.COMMAND_PROJECT_SELECTION,
data.view.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM
);
await slackService.updateModal(data.view.id, modal);

View File

@@ -23,7 +23,8 @@ export const handleMessageAction = async (data: TMessageActionPayload) => {
await handleLinkWorkItem(data);
break;
case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM:
await handleCreateNewWorkItem(data);
case E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE:
await handleCreateNewWorkItem(data, data.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM);
break;
}
};
@@ -112,7 +113,7 @@ const handleLinkWorkItem = async (data: TMessageActionPayload) => {
}
};
const handleCreateNewWorkItem = async (data: TMessageActionPayload) => {
const handleCreateNewWorkItem = async (data: TMessageActionPayload, isWorkItem: boolean = true) => {
// Get the workspace connection for the associated team
const details = await getConnectionDetails(data.team.id, {
id: data.user.id,
@@ -141,17 +142,22 @@ const handleCreateNewWorkItem = async (data: TMessageActionPayload) => {
const projects = await planeClient.project.list(workspaceConnection.workspace_slug);
const filteredProjects = projects.results.filter((project) => project.is_member === true);
const plainTextOptions = convertToSlackOptions(filteredProjects);
const modal = createProjectSelectionModal(plainTextOptions, {
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
message: {
text: data.message.text,
ts: data.message.ts,
blocks: data.message.blocks,
const modal = createProjectSelectionModal(
plainTextOptions,
{
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
message: {
text: data.message.text,
ts: data.message.ts,
blocks: data.message.blocks,
},
channel: {
id: data.channel.id,
},
},
channel: {
id: data.channel.id,
},
});
undefined,
isWorkItem
);
try {
const res = await slackService.openModal(data.trigger_id, modal);

View File

@@ -1,7 +1,8 @@
import { credentials } from "amqplib";
import { E_INTEGRATION_KEYS } from "@plane/etl/core";
import { ContentParser } from "@plane/etl/parser";
import { TSlackIssueEntityData, TViewSubmissionPayload } from "@plane/etl/slack";
import { IssueWithExpanded } from "@plane/sdk";
import { IssueWithExpanded, PlaneUser } from "@plane/sdk";
import { env } from "@/env";
import { CONSTANTS } from "@/helpers/constants";
import { downloadFile } from "@/helpers/utils";
@@ -13,10 +14,13 @@ import { getSlackContentParser } from "../../helpers/content-parser";
import { parseIssueFormData, parseLinkWorkItemFormData } from "../../helpers/parse-issue-form";
import {
E_MESSAGE_ACTION_TYPES,
ParsedIssueData,
ShortcutActionPayload,
SlackPrivateMetadata,
TSlackConnectionDetails,
TSlackWorkspaceConnectionConfig,
} from "../../types/types";
import { createSlackIntakeLinkback } from "../../views/intake-linkback";
import { createSlackLinkback } from "../../views/issue-linkback";
import { createLinkIssueModalView } from "../../views/link-issue-modal";
@@ -41,6 +45,7 @@ export const handleViewSubmission = async (data: TViewSubmissionPayload) => {
case E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION:
return await handleIssueWeblinkViewSubmission(details, data);
case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM:
case E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE:
return await handleCreateNewWorkItemViewSubmission(details, data);
default:
logger.error("Unknown view submission callback id:", data.view.callback_id);
@@ -292,7 +297,7 @@ export const handleCreateNewWorkItemViewSubmission = async (
details: TSlackConnectionDetails,
data: TViewSubmissionPayload
) => {
const { workspaceConnection, slackService, planeClient, botCredentials } = details;
const { workspaceConnection, slackService, planeClient } = details;
try {
const parsedData = parseIssueFormData(data.view.state.values);
@@ -309,8 +314,7 @@ export const handleCreateNewWorkItemViewSubmission = async (
const slackUser = await slackService.getUserInfo(data.user.id);
const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug);
const member = members.find((m: any) => m.email === slackUser?.user.profile.email);
const member = members.find((m: PlaneUser) => m.email === slackUser?.user.profile.email);
const userMap = new Map<string, string>();
config.userMap?.forEach((user) => {
userMap.set(user.slackUser, user.planeUserId);
@@ -321,72 +325,12 @@ export const handleCreateNewWorkItemViewSubmission = async (
teamDomain: data.team.domain,
});
let parsedDescription: string;
try {
parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "<p></p>");
} catch (error) {
logger.error("[SLACK] Error parsing issue description:", error);
// Fallback to the original description or a safe default
parsedDescription = parsedData.description ?? "<p></p>";
}
const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, {
name: parsedData.title,
description_html: parsedDescription,
created_by: member?.id,
state: parsedData.state,
priority: parsedData.priority,
labels: parsedData.labels,
});
const issueWithFields = await planeClient.issue.getIssueWithFields(
workspaceConnection.workspace_slug,
parsedData.project,
issue.id,
["state", "project", "assignees", "labels"]
);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project);
const linkBack = createSlackLinkback(
workspaceConnection.workspace_slug,
issueWithFields,
states.results,
parsedData.enableThreadSync || false
);
// Step 6: Send the appropriate response based on entity type
if (metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
/* For shortcut project selection,
- Send a thread message to the channel
- Create a thread connection if thread sync is enabled
Hence, we need to handle the shortcut project selection in a separate function
*/
await handleShortcutProjectSelection(
slackService,
apiClient,
workspaceConnection,
planeClient,
parsedData,
metadata as SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
linkBack,
issue,
botCredentials
);
} else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) {
/* For command project selection,
- Send a message to the response_url, directly to the channel
Hence, we need to handle the command project selection in a separate function
*/
await handleCommandProjectSelection(
slackService,
metadata as SlackPrivateMetadata<typeof ENTITIES.COMMAND_PROJECT_SELECTION>,
linkBack
);
if (data.view.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE) {
await createIntakeIssueFromViewSubmission(parsedData, details, metadata, member, details, parser);
} else {
await createWorkItemFromViewSubmission(data.team.domain, parsedData, details, metadata, member, details, parser);
}
} catch (error: any) {
// Handle any errors
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
if (isPermissionError) {
logger.error("Permission error in handleCreateNewWorkItemViewSubmission:", error);
@@ -397,8 +341,140 @@ export const handleCreateNewWorkItemViewSubmission = async (
}
};
async function createIntakeIssueFromViewSubmission(
parsedData: ParsedIssueData,
credentials: TSlackConnectionDetails,
metadata: SlackPrivateMetadata,
member: PlaneUser | undefined,
details: TSlackConnectionDetails,
parser: ContentParser
) {
const { workspaceConnection, planeClient, slackService } = details;
let parsedDescription: string;
try {
parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "<p></p>");
} catch (error) {
logger.error("[SLACK] Error parsing issue description:", error);
// Fallback to the original description or a safe default
parsedDescription = parsedData.description ?? "<p></p>";
}
const issue = await planeClient.intake.create(workspaceConnection.workspace_slug, parsedData.project, {
issue: {
name: parsedData.title,
description_html: parsedDescription,
created_by: member?.id,
state: parsedData.state,
priority: parsedData.priority || "none",
labels: parsedData.labels || [],
project: parsedData.project,
},
});
const linkBack = createSlackIntakeLinkback(workspaceConnection.workspace_slug, issue, false);
if (metadata.entityPayload.type === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
const payload = metadata.entityPayload as ShortcutActionPayload;
const channelId = payload.channel.id;
const messageTs = payload.message.ts;
if (messageTs) {
await slackService.sendThreadMessage(
channelId,
messageTs,
{
text: "Intake issue created successfully. ✅",
blocks: linkBack.blocks,
},
issue,
false
);
} else {
logger.error("No message ts found in entity payload");
}
}
}
async function createWorkItemFromViewSubmission(
teamDomain: string,
parsedData: ParsedIssueData,
credentials: TSlackConnectionDetails,
metadata: SlackPrivateMetadata,
member: PlaneUser | undefined,
details: TSlackConnectionDetails,
parser: ContentParser
) {
const { workspaceConnection, slackService, planeClient } = details;
let parsedDescription: string;
try {
parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "<p></p>");
} catch (error) {
logger.error("[SLACK] Error parsing issue description:", error);
// Fallback to the original description or a safe default
parsedDescription = parsedData.description ?? "<p></p>";
}
const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, {
name: parsedData.title,
description_html: parsedDescription,
created_by: member?.id,
state: parsedData.state,
priority: parsedData.priority,
labels: parsedData.labels,
});
const issueWithFields = await planeClient.issue.getIssueWithFields(
workspaceConnection.workspace_slug,
parsedData.project,
issue.id,
["state", "project", "assignees", "labels"]
);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project);
const linkBack = createSlackLinkback(
workspaceConnection.workspace_slug,
issueWithFields,
states.results,
parsedData.enableThreadSync || false
);
// Step 6: Send the appropriate response based on entity type
if (metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
/* For shortcut project selection,
- Send a thread message to the channel
- Create a thread connection if thread sync is enabled
Hence, we need to handle the shortcut project selection in a separate function
*/
await handleShortcutProjectSelection(
teamDomain,
slackService,
apiClient,
workspaceConnection,
planeClient,
parsedData,
metadata as SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
linkBack,
issue,
credentials
);
} else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) {
/* For command project selection,
- Send a message to the response_url, directly to the channel
Hence, we need to handle the command project selection in a separate function
*/
await handleCommandProjectSelection(
slackService,
metadata as SlackPrivateMetadata<typeof ENTITIES.COMMAND_PROJECT_SELECTION>,
linkBack
);
}
}
// Helper function for shortcut project selection handling
async function handleShortcutProjectSelection(
teamDomain: string,
slackService: any,
apiClient: any,
workspaceConnection: any,
@@ -437,6 +513,11 @@ async function handleShortcutProjectSelection(
await Promise.all(fileUploadPromises);
}
}
// Attach link of the slack thread to the issue
const title = "Connected to Slack thread";
const link = `https://${teamDomain}.slack.com/archives/${metadata.entityPayload.channel.id}/p${metadata.entityPayload.message?.ts}`;
await planeClient.issue.createLink(workspaceConnection.workspace_slug, parsedData.project, issue.id, title, link);
}
// Send thread message

View File

@@ -1,6 +1,7 @@
export enum CONSTANTS {
NO_PERMISSION_ERROR = "do not have permission",
NO_PERMISSION_ERROR_MESSAGE = "You don't have permission to access this resource.",
INTAKE_NOT_ENABLED_ERROR = "Intake is not enabled for this project. Please contact your administrator.",
SOMETHING_WENT_WRONG = "Something went wrong. Please try again.",
}

View File

@@ -15,7 +15,7 @@ import { UserService } from "@/services/user.service";
// types
import { ClientOptions } from "@/types/types";
import { AssetService } from "./services";
import { AssetService, IntakeService } from "./services";
export class Client {
options: ClientOptions;
@@ -33,6 +33,7 @@ export class Client {
issueProperty: IssuePropertyService;
issuePropertyOption: IssuePropertyOptionService;
issuePropertyValue: IssuePropertyValueService;
intake: IntakeService;
constructor(options: ClientOptions) {
this.options = options;
@@ -50,5 +51,6 @@ export class Client {
this.issueProperty = new IssuePropertyService(options);
this.issuePropertyOption = new IssuePropertyOptionService(options);
this.issuePropertyValue = new IssuePropertyValueService(options);
this.intake = new IntakeService(options);
}
}

View File

@@ -1,3 +1,4 @@
export * from "./api.service";
export * from "./issue.service";
export * from "./asset.service";
export * from "./intake.service";

View File

@@ -0,0 +1,43 @@
import { TInboxIssueStatus, TInboxIssueWithPagination } from "@plane/types";
import { APIService } from "@/services/api.service";
// types
import { ExIntakeIssue, ExIssue } from "@/types/types";
// constants
export type IntakeIssueCreatePayload = {
issue: Partial<ExIssue>;
};
export class IntakeService extends APIService {
/**
* Get all intake issues for a project
*/
async list(
workspaceSlug: string,
projectId: string,
params?: {
status?: TInboxIssueStatus | TInboxIssueStatus[];
cursor?: string;
per_page?: number;
}
): Promise<TInboxIssueWithPagination> {
return this.get(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/intake-issues/`, {
params,
})
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Create a new intake issue
*/
async create(workspaceSlug: string, projectId: string, payload: IntakeIssueCreatePayload): Promise<ExIntakeIssue> {
return this.post(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/intake-issues/`, payload)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -183,6 +183,29 @@ export type ExIssueAttachment = {
external_source: string;
};
export type ExIntakeIssue<T = ExIssue> = {
id: string;
issue_detail: T;
inbox: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
status: number;
snoozed_till: string | null;
source: string;
source_email: string | null;
external_source: string | null;
external_id: string | null;
created_by: string;
updated_by: string | null;
project: string;
workspace: string;
intake: string;
issue: string;
duplicate_to: string | null;
extra: Record<string, any>;
}
/* ----------------- Project Type --------------------- */
type IProject = {
id: string;
@@ -206,6 +229,7 @@ type IProject = {
issue_views_view: boolean;
page_view: boolean;
inbox_view: boolean;
intake_view: boolean;
is_time_tracking_enabled: boolean;
is_issue_type_enabled: boolean;
cover_image: string;