diff --git a/apps/silo/jest.config.js b/apps/silo/jest.config.js index 9dd0a52d32..f5fe66c9e2 100644 --- a/apps/silo/jest.config.js +++ b/apps/silo/jest.config.js @@ -1,10 +1,10 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ export default { moduleNameMapper: { - '^@/(.*)$': '/src/$1' + "^@/(.*)$": "/src/$1", }, - testEnvironment: 'node', + testEnvironment: "node", transform: { - '^.+\\.tsx?$': 'ts-jest' - } + "^.+\\.tsx?$": "ts-jest", + }, }; diff --git a/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/__test__/assets/parsed-sample.html b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/__test__/assets/parsed-sample.html index 9c3faf9c99..99446239ac 100644 --- a/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/__test__/assets/parsed-sample.html +++ b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/__test__/assets/parsed-sample.html @@ -1,34 +1,436 @@
-

Man confluence feels quality to me actually, what is this font exactly?

Let’s go with the basics and see how things go for us.

IMG_6982.jpgScreen Recording 2025-02-13 at 7.40.14 PM.mov

  • Okay here an action item, a checklist basically
  • Ooooooo, I can assign this to someone as well, like a story I understand it
  • Can I add color to it? Let’s see…… I can actually add color here
  • Let’s see if highlight works for them, it’s enterprisy but sturdy as well
  • Let me try to mention someone and assign it to them once? Henit Chobisa , okay…..

Let’s try everything then

-
console.log("halo")
-

Let’s try more formatting options, I wanna see what comes and what not

Here I am putting a mention together Henit Chobisa

Hello I am putting a quote here, I don’t know how that thing came up, weird!

IMG_6957 (1).jpg

  1. One

  2. Two

  3. Three

  4. Four

  • Dash 1

  • Dash 2

  • Dash 3

  • Dash 4

A Table

It’s not ugly

But not good either

Judged

One

Two

Three

Four

😃

, an emoji, I don’t think this can be touched by anyone

Linear docs too has expands, but then we need to know how do we exactly arrange them in plane

Henit Chobisa dude, mention is also there I see that, good thing

STATUS TEXT , It feels like text with highlight nothing else to me ngl

SECOND STATUS , just add a highlight over text and call it STATUS

Status

GRAY STATUS BLUE STATUS GREEN STATUS YELLOW STATUS RED STATUS PURPLE STATUS

Okay, here we go! Our typical callout block with emojis, I think it’s easy for us to recreate this, but we need to see if we can add colors to this thing.

-

Can I add image to it?

You can add anything to everything here, it’s a little screwed up ngl, gotta see what to be done.

Screen Recording 2025-02-13 at 7.14.09 PM.mov

-

Date is just date, it seems a custom block over which they added a selecteor such that when you click it then a modal opens and you’ll be able to select the date from that thing.

What is a decision here man!
Dude that looks a little crazy to me, it’s just a callout man, there is nothing more to it.

Hmmmmmmm, flavors of callout in front of us……………..

-

Dude, this is really crazy, it should be the same right?

-

Again? Are you kidding me here?

Some information

Success

Cancel

-

Some emoji that I don’t know about

-

+

+ Man confluence feels quality to me actually, what is this font exactly? +

+

Let’s go with the basics and see how things go for us.

+ IMG_6982.jpgScreen Recording 2025-02-13 at 7.40.14 PM.mov +

+ +

+
    +
  • + Okay here an action item, a checklist basically +
  • +
  • + Ooooooo, I can assign this to someone as well, like a story I understand it +
  • +
  • + Can + I add color to it? Let’s see…… I can actually add color here +
  • +
  • + Let’s see if highlight works for them, it’s enterprisy + but sturdy as well +
  • +
  • + Let me try to mention someone and assign it to them once? + Henit Chobisa + , okay….. +
  • +
+

Let’s try everything then

+
+
+
+console.log("halo")
+
+
+

+ Let’s try more formatting options, I wanna see what comes and what not +

+

+ Here I am putting a mention together + Henit Chobisa +

+
+

Hello I am putting a quote here, I don’t know how that thing came up, weird!

+ IMG_6957 (1).jpg +

+
+
    +
  1. One

  2. +
  3. Two

  4. +
  5. Three

  6. +
  7. Four

  8. +
+
    +
  • Dash 1

  • +
  • Dash 2

  • +
  • Dash 3

  • +
  • Dash 4

  • +
+
+
+ + + + + + + + + + + + + + +
+

A Table

+
+

It’s not ugly

+
+

But not good either

+
+

Judged

+

One

Two

Three

Four

+
+

+

😃

+ , an emoji, I don’t think this can be touched by anyone +
+

Linear docs too has expands, but then we need to know how do we exactly arrange them in plane

+ +

+ Henit Chobisa + dude, mention is also there I see that, good + thing +

+
+

+ STATUS TEXT + , It feels like text with highlight nothing else to me ngl +

+

+ SECOND STATUS + , just add a highlight over text and call it + STATUS +

+

Status

+

+ GRAY STATUS + BLUE STATUS + GREEN STATUS + YELLOW STATUS + RED STATUS + PURPLE STATUS +

+

+ Okay, here we go! Our typical callout block with emojis, I think it’s easy for us to recreate this, but we + need to see if we can add colors to this thing. +

+
+

Can I add image to it?

+ +

You can add anything to everything here, it’s a little screwed up ngl, gotta see what to be done.

+ Screen Recording 2025-02-13 at 7.14.09 PM.mov +

+
+

+ Date is just date, it seems a custom block over + which they added a selecteor such that when you click it then a modal opens and you’ll be able to select the + date from that thing. +

+
+ What is a decision here man!
Dude that looks a little crazy to me, it’s just a callout man, there is + nothing more to it. +
+

Hmmmmmmm, flavors of callout in front of us……………..

+
+

Dude, this is really crazy, it should be the same right?

+
+
+

Again? Are you kidding me here?

+
+
+

Some information

+
+
+

Success

+
+
+

Cancel

+
+
+

Some emoji that I don’t know about

+
+
+ - - - - + + + + - - - - - -
- Title - - Decisions -
TitleDecisions
- Let me create a new page - -
What is a decision here man!
Dude that looks a little crazy to me, it’s just a callout man, there is nothing more to it.
-
-

Dude! What the hell! You were asked to create abstractions, here you’re creating concepts. Hmmm debatable to me.

- - + + + Let me create a new page + + +
+ What is a decision here man!
Dude that looks a little crazy to me, it’s just a callout man, there + is nothing more to it. +
+ + + + +

+ Dude! What the hell! You were asked to create abstractions, here you’re creating concepts. Hmmm debatable to + me. +

+ diff --git a/apps/silo/src/apps/slack/types/types.ts b/apps/silo/src/apps/slack/types/types.ts index 718aa3d895..f62d16d33d 100644 --- a/apps/silo/src/apps/slack/types/types.ts +++ b/apps/silo/src/apps/slack/types/types.ts @@ -95,6 +95,7 @@ export type SlackPrivateMetadata { + 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(); +} diff --git a/apps/silo/src/apps/slack/views/issue-modal-full.ts b/apps/silo/src/apps/slack/views/issue-modal-full.ts index 3434b89358..ebcf65e681 100644 --- a/apps/silo/src/apps/slack/views/issue-modal-full.ts +++ b/apps/silo/src/apps/slack/views/issue-modal-full.ts @@ -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, diff --git a/apps/silo/src/apps/slack/views/project-select-modal.ts b/apps/silo/src/apps/slack/views/project-select-modal.ts index 56cb19492d..1e15124365 100644 --- a/apps/silo/src/apps/slack/views/project-select-modal.ts +++ b/apps/silo/src/apps/slack/views/project-select-modal.ts @@ -5,22 +5,24 @@ import { E_MESSAGE_ACTION_TYPES, ShortcutActionPayload } from "../types/types"; export const createProjectSelectionModal = ( projects: Array, 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}`, + }, + ], + }, + ] + : []), ], }); diff --git a/apps/silo/src/apps/slack/worker/handlers/block-actions.ts b/apps/silo/src/apps/slack/worker/handlers/block-actions.ts index 759ef7f901..435c72d076 100644 --- a/apps/silo/src/apps/slack/worker/handlers/block-actions.ts +++ b/apps/silo/src/apps/slack/worker/handlers/block-actions.ts @@ -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); diff --git a/apps/silo/src/apps/slack/worker/handlers/message-action.ts b/apps/silo/src/apps/slack/worker/handlers/message-action.ts index 60e6b6eadd..695296cbc9 100644 --- a/apps/silo/src/apps/slack/worker/handlers/message-action.ts +++ b/apps/silo/src/apps/slack/worker/handlers/message-action.ts @@ -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); diff --git a/apps/silo/src/apps/slack/worker/handlers/view-submission.ts b/apps/silo/src/apps/slack/worker/handlers/view-submission.ts index 90ebed9e09..60abc5745b 100644 --- a/apps/silo/src/apps/slack/worker/handlers/view-submission.ts +++ b/apps/silo/src/apps/slack/worker/handlers/view-submission.ts @@ -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(); 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 ?? "

"); - } catch (error) { - logger.error("[SLACK] Error parsing issue description:", error); - // Fallback to the original description or a safe default - parsedDescription = parsedData.description ?? "

"; - } - - 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, - 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, - 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 ?? "

"); + } catch (error) { + logger.error("[SLACK] Error parsing issue description:", error); + // Fallback to the original description or a safe default + parsedDescription = parsedData.description ?? "

"; + } + + 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 ?? "

"); + } catch (error) { + logger.error("[SLACK] Error parsing issue description:", error); + // Fallback to the original description or a safe default + parsedDescription = parsedData.description ?? "

"; + } + + 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, + 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, + 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 diff --git a/apps/silo/src/helpers/constants.ts b/apps/silo/src/helpers/constants.ts index 40e463c17d..687f934e0a 100644 --- a/apps/silo/src/helpers/constants.ts +++ b/apps/silo/src/helpers/constants.ts @@ -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.", } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 86ce0f7a0e..24517f5a3f 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -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); } } diff --git a/packages/sdk/src/services/index.ts b/packages/sdk/src/services/index.ts index 203bf0ffe3..717ec76a1c 100644 --- a/packages/sdk/src/services/index.ts +++ b/packages/sdk/src/services/index.ts @@ -1,3 +1,4 @@ export * from "./api.service"; export * from "./issue.service"; export * from "./asset.service"; +export * from "./intake.service"; diff --git a/packages/sdk/src/services/intake.service.ts b/packages/sdk/src/services/intake.service.ts new file mode 100644 index 0000000000..17173494fa --- /dev/null +++ b/packages/sdk/src/services/intake.service.ts @@ -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; +}; + +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 { + 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 { + return this.post(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/intake-issues/`, payload) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/types/types.ts b/packages/sdk/src/types/types.ts index ba94b2db0c..682c368432 100644 --- a/packages/sdk/src/types/types.ts +++ b/packages/sdk/src/types/types.ts @@ -183,6 +183,29 @@ export type ExIssueAttachment = { external_source: string; }; +export type ExIntakeIssue = { + 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; +} + /* ----------------- 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;