From f146b5a5a96f81468915c4f272ea5069dfe63e77 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Fri, 28 Aug 2026 12:46:50 +0100 Subject: [PATCH 1/2] remove og tag --- apps/web/app/root.tsx | 17 - .../core/components/dropdowns/module/base.tsx | 2 + .../issues/bulk-operations/root.tsx | 157 ++++++++- .../issues/bulk-operations/upgrade-banner.tsx | 36 --- .../components/default-properties.tsx | 304 ++++++++---------- .../issue-modal/components/top-properties.tsx | 28 ++ .../core/hooks/use-bulk-operation-status.ts | 2 +- 7 files changed, 326 insertions(+), 220 deletions(-) delete mode 100644 apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index 08a38e632eb..2b566c22cde 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -19,7 +19,6 @@ import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; import faviconIco from "@/app/assets/favicon/favicon.ico?url"; import icon180 from "@/app/assets/icons/icon-180x180.png?url"; import icon512 from "@/app/assets/icons/icon-512x512.png?url"; -import ogImage from "@/app/assets/og-image.png?url"; import globalStyles from "@/styles/globals.css?url"; import type { Route } from "./+types/root"; // components @@ -99,27 +98,11 @@ export function Layout({ children }: { children: ReactNode }) { export const meta: Route.MetaFunction = () => [ { title: APP_TITLE }, { name: "description", content: SITE_DESCRIPTION }, - { property: "og:title", content: APP_TITLE }, - { - property: "og:description", - content: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", - }, - { property: "og:url", content: "https://app.plane.so/" }, - { property: "og:image", content: ogImage }, - { property: "og:image:width", content: "1200" }, - { property: "og:image:height", content: "630" }, - { property: "og:image:alt", content: "Plane - Modern project management" }, { name: "keywords", content: "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", }, - { name: "twitter:site", content: "@planepowers" }, - { name: "twitter:card", content: "summary_large_image" }, - { name: "twitter:image", content: ogImage }, - { name: "twitter:image:width", content: "1200" }, - { name: "twitter:image:height", content: "630" }, - { name: "twitter:image:alt", content: "Plane - Modern project management" }, ]; export default function Root() { diff --git a/apps/web/core/components/dropdowns/module/base.tsx b/apps/web/core/components/dropdowns/module/base.tsx index 6487e57ba78..6087c9fd8ea 100644 --- a/apps/web/core/components/dropdowns/module/base.tsx +++ b/apps/web/core/components/dropdowns/module/base.tsx @@ -64,6 +64,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM multiple, onChange, onClose, + onDropdownOpen, placeholder = "", placement, projectId, @@ -194,6 +195,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM getModuleById={getModuleById} moduleIds={moduleIds} value={value} + onDropdownOpen={onDropdownOpen} /> )} diff --git a/apps/web/core/components/issues/bulk-operations/root.tsx b/apps/web/core/components/issues/bulk-operations/root.tsx index 567fd12d232..e389626730c 100644 --- a/apps/web/core/components/issues/bulk-operations/root.tsx +++ b/apps/web/core/components/issues/bulk-operations/root.tsx @@ -4,11 +4,22 @@ * See the LICENSE file for details. */ +import { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Trash2, X } from "lucide-react"; +// plane imports +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TBulkOperationsPayload, TIssuePriorities } from "@plane/types"; +import { AlertModalCore } from "@plane/ui"; +import { cn } from "@plane/utils"; // components -import { BulkOperationsUpgradeBanner } from "@/components/issues/bulk-operations/upgrade-banner"; +import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { StateDropdown } from "@/components/dropdowns/state/dropdown"; // hooks +import { useIssues } from "@/hooks/store/use-issues"; import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import type { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { @@ -18,10 +29,148 @@ type Props = { export const IssueBulkOperationsRoot = observer(function IssueBulkOperationsRoot(props: Props) { const { className, selectionHelpers } = props; + // router + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug?.toString(); // store hooks - const { isSelectionActive } = useMultipleSelectStore(); + const storeType = useIssueStoreType(); + const { issues, issueMap } = useIssues(storeType); + const { isSelectionActive, selectedEntityIds } = useMultipleSelectStore(); + // states + const [isApplying, setIsApplying] = useState(false); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null; + if (!isSelectionActive || selectionHelpers.isSelectionDisabled || !workspaceSlug) return null; - return ; + // State ids only make sense within one project, so bulk state changes are grouped and + // applied per project. Priority is a fixed, project-agnostic enum, so it can always apply. + const projectGroups = new Map(); + selectedEntityIds.forEach((issueId) => { + const projectId = issueMap?.[issueId]?.project_id; + if (!projectId) return; + projectGroups.set(projectId, [...(projectGroups.get(projectId) ?? []), issueId]); + }); + const projectIds = Array.from(projectGroups.keys()); + const singleProjectId = projectIds.length === 1 ? projectIds[0] : undefined; + + const applyToSelection = async (properties: TBulkOperationsPayload["properties"]) => { + if (projectGroups.size === 0) return; + setIsApplying(true); + try { + await Promise.all( + Array.from(projectGroups.entries()).map(([projectId, issueIds]) => + issues.bulkUpdateProperties(workspaceSlug, projectId, { issue_ids: issueIds, properties }) + ) + ); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Updated", + message: `${selectedEntityIds.length} work item${selectedEntityIds.length === 1 ? "" : "s"} updated`, + }); + } catch (_error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error", + message: "Could not update the selected work items", + }); + } finally { + setIsApplying(false); + } + }; + + const handleBulkDelete = async () => { + if (projectGroups.size === 0) return; + setIsDeleting(true); + const deletedCount = selectedEntityIds.length; + try { + await Promise.all( + Array.from(projectGroups.entries()).map(([projectId, issueIds]) => + issues.removeBulkIssues(workspaceSlug, projectId, issueIds) + ) + ); + selectionHelpers.handleClearSelection(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Deleted", + message: `${deletedCount} work item${deletedCount === 1 ? "" : "s"} deleted`, + }); + } catch (_error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error", + message: "Could not delete the selected work items", + }); + } finally { + setIsDeleting(false); + setIsDeleteConfirmOpen(false); + } + }; + + return ( + <> + setIsDeleteConfirmOpen(false)} + handleSubmit={handleBulkDelete} + isSubmitting={isDeleting} + title="Delete work items" + content={ + <> + {`Are you sure you want to delete `} + + {selectedEntityIds.length} work item{selectedEntityIds.length === 1 ? "" : "s"} + + {`? All of the data related to ${selectedEntityIds.length === 1 ? "it" : "them"} will be permanently removed. This action cannot be undone.`} + + } + /> +
+
+ + {selectedEntityIds.length} selected + +
+
+ applyToSelection({ priority })} + buttonVariant="border-with-text" + disabled={isApplying} + /> +
+ {singleProjectId && ( +
+ stateId && applyToSelection({ state_id: stateId })} + projectId={singleProjectId} + buttonVariant="border-with-text" + disabled={isApplying} + /> +
+ )} +
+ + +
+
+ + ); }); diff --git a/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx b/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx deleted file mode 100644 index bfd33369020..00000000000 --- a/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2023-present Plane Software, Inc. and contributors - * SPDX-License-Identifier: AGPL-3.0-only - * See the LICENSE file for details. - */ - -import { MARKETING_PLANE_ONE_PAGE_LINK } from "@plane/constants"; -import { getButtonStyling } from "@plane/propel/button"; -import { cn } from "@plane/utils"; - -type Props = { - className?: string; -}; - -export function BulkOperationsUpgradeBanner(props: Props) { - const { className } = props; - - return ( -
-
-

- Change state, priority, and more for several work items at once. Save three minutes on an average per - operation. -

- - Upgrade to One - -
-
- ); -} diff --git a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx index 9650adea7d7..d182eb26f38 100644 --- a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -20,7 +20,6 @@ import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; import { CycleDropdown } from "@/components/dropdowns/cycle"; import { DateDropdown } from "@/components/dropdowns/date"; import { EstimateDropdown } from "@/components/dropdowns/estimate"; -import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal"; import { IssueLabelSelect } from "@/components/issues/select"; import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier"; @@ -81,202 +80,183 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p maxDate?.setDate(maxDate.getDate()); return ( -
- ( -
- { - onChange(labelIds); - handleFormChange(); - }} - projectId={projectId ?? undefined} - tabIndex={getIndex("label_ids")} - createLabelEnabled={!!canCreateLabel} - groupsToShow="rest" - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - maxDate={maxDate ?? undefined} - placeholder={t("start_date")} - tabIndex={getIndex("start_date")} - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - minDate={minDate ?? undefined} - placeholder={t("due_date")} - tabIndex={getIndex("target_date")} - /> -
- )} - /> - {projectDetails?.cycle_view && ( +
+
(
- { - onChange(cycleId); + { + onChange(labelIds); handleFormChange(); }} - placeholder={t("cycle.label", { count: 1 })} - value={value} - buttonVariant="border-with-text" - tabIndex={getIndex("cycle_id")} + projectId={projectId ?? undefined} + tabIndex={getIndex("label_ids")} + createLabelEnabled={!!canCreateLabel} + groupsToShow="rest" />
)} /> - )} - {projectDetails?.module_view && workspaceSlug && ( +
+
(
- { - onChange(moduleIds); + { + onChange(date ? renderFormattedPayloadDate(date) : null); handleFormChange(); }} - placeholder={t("modules")} buttonVariant="border-with-text" - tabIndex={getIndex("module_ids")} - multiple - showCount + maxDate={maxDate ?? undefined} + placeholder={t("start_date")} + tabIndex={getIndex("start_date")} />
)} /> - )} - {projectId && areEstimateEnabledByProjectId(projectId) && ( (
- { - onChange(estimatePoint); + { + onChange(date ? renderFormattedPayloadDate(date) : null); handleFormChange(); }} - projectId={projectId} buttonVariant="border-with-text" - tabIndex={getIndex("estimate_point")} - placeholder={t("estimate")} + minDate={minDate ?? undefined} + placeholder={t("due_date")} + tabIndex={getIndex("target_date")} />
)} /> - )} -
- {parentId ? ( - - {selectedParentIssue?.project_id && ( - - )} - - } - placement="bottom-start" - className="h-full w-full" - customButtonClassName="h-full" - tabIndex={getIndex("parent_id")} - > - <> - setParentIssueListModalOpen(true)}> - {t("change_parent_issue")} - - ( - { - onChange(null); - handleFormChange(); - }} - > - {t("remove_parent_issue")} - - )} - /> - - - ) : ( - + {projectDetails?.cycle_view && ( + ( +
+ { + onChange(cycleId); + handleFormChange(); + }} + placeholder={t("cycle.label", { count: 1 })} + value={value} + buttonVariant="border-with-text" + tabIndex={getIndex("cycle_id")} + /> +
+ )} + /> )} -
- ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - handleFormChange(); - setSelectedParentIssue(issue); - }} - projectId={projectId ?? undefined} - issueId={isDraft ? undefined : id} + {projectId && areEstimateEnabledByProjectId(projectId) && ( + ( +
+ { + onChange(estimatePoint); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + tabIndex={getIndex("estimate_point")} + placeholder={t("estimate")} + /> +
+ )} /> )} - /> +
+ {parentId ? ( + + {selectedParentIssue?.project_id && ( + + )} + + } + placement="bottom-start" + className="h-full w-full" + customButtonClassName="h-full" + tabIndex={getIndex("parent_id")} + > + <> + setParentIssueListModalOpen(true)}> + {t("change_parent_issue")} + + ( + { + onChange(null); + handleFormChange(); + }} + > + {t("remove_parent_issue")} + + )} + /> + + + ) : ( + + )} +
+ ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + handleFormChange(); + setSelectedParentIssue(issue); + }} + projectId={projectId ?? undefined} + issueId={isDraft ? undefined : id} + /> + )} + /> +
); }); diff --git a/apps/web/core/components/issues/issue-modal/components/top-properties.tsx b/apps/web/core/components/issues/issue-modal/components/top-properties.tsx index bdc93d430df..48f3b3863c1 100644 --- a/apps/web/core/components/issues/issue-modal/components/top-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/top-properties.tsx @@ -13,10 +13,12 @@ import type { TIssue } from "@plane/types"; import { getTabIndex } from "@plane/utils"; // components import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; import { PriorityDropdown } from "@/components/dropdowns/priority"; import { StateDropdown } from "@/components/dropdowns/state/dropdown"; import { IssueLabelSelect } from "@/components/issues/select"; // hooks +import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -34,8 +36,11 @@ export const IssueTopProperties = observer(function IssueTopProperties(props: TI const { t } = useTranslation(); const { isMobile } = usePlatformOS(); const { allowPermissions } = useUserPermissions(); + const { getProjectById } = useProject(); const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); + const projectDetails = getProjectById(projectId); + const canCreateLabel = projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); @@ -115,6 +120,29 @@ export const IssueTopProperties = observer(function IssueTopProperties(props: TI /> )} /> + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ { + onChange(moduleIds); + handleFormChange(); + }} + placeholder={t("modules")} + buttonVariant="border-with-text" + tabIndex={getIndex("module_ids")} + multiple + showCount + /> +
+ )} + /> + )}
); }); diff --git a/apps/web/core/hooks/use-bulk-operation-status.ts b/apps/web/core/hooks/use-bulk-operation-status.ts index 539779061ff..f60ecc5c2fe 100644 --- a/apps/web/core/hooks/use-bulk-operation-status.ts +++ b/apps/web/core/hooks/use-bulk-operation-status.ts @@ -4,4 +4,4 @@ * See the LICENSE file for details. */ -export const useBulkOperationStatus = () => false; +export const useBulkOperationStatus = () => true; From 2699a9a612bb426d4ba6ba5b6c799e90a1ad308c Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Sat, 29 Aug 2026 16:30:19 +0100 Subject: [PATCH 2/2] feat(api): expose work item subscribers on the API-key surface Upstream exposes issue subscribers only on the session-authenticated app API, where `subscribe` can act on `request.user` and nobody else. That is unusable for an integration: an API token authenticates as its own bot user, so there is no way for an external system to say "notify THIS person about THIS work item". We need exactly that. Bug reports filed from the Psyclo apps land in Intake as work items owned by the token user, and a reporter who agreed to be contacted has to receive Plane's own comment notification email for the thread to be two-way. Everything else that loop needs already exists on /api/v1/ -- workspace invitations, project members, work item comments -- this was the one gap. Adds GET/POST/DELETE on workspaces//projects//work-items//subscribers/ POST takes either `subscriber` (a user id) or `email`, because a calling system generally knows the person by address and would otherwise have to page the whole member list to translate it. Reads are ProjectEntityPermission; changing someone else's notifications is not ordinary entity access, so writes require ProjectAdminPermission. The project-membership check in POST is deliberate rather than merely documented: `bgtasks/notification_task.py` filters recipients to active ProjectMember rows before intersecting with IssueSubscriber, so accepting a non-member would create a row that looks correct in the database and silently never delivers anything. Better to reject it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SsBurBNYE4Wxpx2k6atqcj --- apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/subscriber.py | 20 ++++ apps/api/plane/api/views/__init__.py | 2 + apps/api/plane/api/views/subscriber.py | 158 +++++++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 apps/api/plane/api/urls/subscriber.py create mode 100644 apps/api/plane/api/views/subscriber.py diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index e4f20c4aee6..50f24ec9777 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -15,6 +15,7 @@ from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns from .sticky import urlpatterns as sticky_patterns +from .subscriber import urlpatterns as subscriber_patterns urlpatterns = [ *asset_patterns, @@ -30,4 +31,5 @@ *work_item_patterns, *invite_patterns, *sticky_patterns, + *subscriber_patterns, ] diff --git a/apps/api/plane/api/urls/subscriber.py b/apps/api/plane/api/urls/subscriber.py new file mode 100644 index 00000000000..72d5bd6dc10 --- /dev/null +++ b/apps/api/plane/api/urls/subscriber.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views import WorkItemSubscriberAPIEndpoint + +urlpatterns = [ + path( + "workspaces//projects//work-items//subscribers/", + WorkItemSubscriberAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-subscribers", + ), + path( + "workspaces//projects//work-items//subscribers//", + WorkItemSubscriberAPIEndpoint.as_view(http_method_names=["delete"]), + name="work-item-subscriber", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 691bef63ad3..28aa783166d 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -73,6 +73,8 @@ from .sticky import StickyViewSet +from .subscriber import WorkItemSubscriberAPIEndpoint + from .release import ( ReleaseCandidateAPIEndpoint, ReleaseChangelogAPIEndpoint, diff --git a/apps/api/plane/api/views/subscriber.py b/apps/api/plane/api/views/subscriber.py new file mode 100644 index 00000000000..f91cab105dd --- /dev/null +++ b/apps/api/plane/api/views/subscriber.py @@ -0,0 +1,158 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse, extend_schema + +# Module imports +from plane.api.views.base import BaseAPIView +from plane.db.models import Issue, IssueSubscriber, ProjectMember, User +from plane.utils.openapi import ( + FORBIDDEN_RESPONSE, + PROJECT_ID_PARAMETER, + PROJECT_NOT_FOUND_RESPONSE, + UNAUTHORIZED_RESPONSE, + WORKSPACE_SLUG_PARAMETER, +) +from plane.utils.permissions import ProjectAdminPermission, ProjectEntityPermission + + +class WorkItemSubscriberAPIEndpoint(BaseAPIView): + """Manage who is notified about a work item, over the API-key surface. + + Upstream exposes issue subscribers only on the session-authenticated app API + (`/api/workspaces/.../issues//issue-subscribers/`), where `subscribe` + can only ever act on `request.user`. That is unusable for an integration: + an API token acts as its own bot user, so there is no way for an external + system to say "notify THIS person about THIS work item". + + We need exactly that. Bug reports filed from the Psyclo apps land in Intake + as work items owned by the token user, and a reporter who ticked "allow + Psyclopedia to contact me" has to receive Plane's own comment notification + email so the thread can be two-way. See the notification recipient filter in + `plane/bgtasks/notification_task.py` -- a subscriber is only mailed if they + are ALSO an active `ProjectMember`, which is why that is validated here + rather than left to fail silently later. + """ + + permission_classes = [ProjectEntityPermission] + + def get_permissions(self): + # Reading who is subscribed is ordinary project-entity access; changing + # someone else's notifications is not, so writes require project admin. + if self.request.method == "GET": + return [ProjectEntityPermission()] + return [ProjectAdminPermission()] + + @extend_schema( + operation_id="get_work_item_subscribers", + summary="List work item subscribers", + description="Retrieve the users subscribed to notifications for a work item.", + tags=["Work Items"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 200: OpenApiResponse(description="List of subscriber user ids"), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + subscriber_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=issue_id + ).values_list("subscriber_id", flat=True) + return Response( + [str(subscriber_id) for subscriber_id in subscriber_ids], + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="create_work_item_subscriber", + summary="Subscribe a user to a work item", + description=( + "Subscribe a user to notifications for a work item. Accepts either " + "`subscriber` (a user id) or `email`. The user must already be an " + "active member of the project, otherwise Plane will not send them " + "notifications." + ), + tags=["Work Items"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 201: OpenApiResponse(description="Subscriber created"), + 200: OpenApiResponse(description="Subscriber already existed"), + 400: OpenApiResponse(description="Invalid subscriber"), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, issue_id): + issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first() + if issue is None: + return Response({"error": "Work item does not exist"}, status=status.HTTP_404_NOT_FOUND) + + # `email` is accepted alongside `subscriber` because the calling system + # generally knows the person by address, not by their Plane user id, and + # would otherwise have to page the whole member list to translate it. + subscriber_id = request.data.get("subscriber") + email = request.data.get("email") + if subscriber_id: + user = User.objects.filter(pk=subscriber_id).first() + elif email: + user = User.objects.filter(email__iexact=str(email).strip()).first() + else: + return Response( + {"error": "Either subscriber or email is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if user is None: + return Response({"error": "User does not exist"}, status=status.HTTP_400_BAD_REQUEST) + + # Enforced here, not merely documented: a subscriber who is not an + # active project member is dropped by the notification task's recipient + # filter, so accepting one would create a row that looks correct and + # silently never delivers anything. + if not ProjectMember.objects.filter(project_id=project_id, member=user, is_active=True).exists(): + return Response( + {"error": "User is not an active member of this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + _, created = IssueSubscriber.objects.get_or_create( + issue_id=issue_id, + subscriber=user, + defaults={"project_id": project_id, "workspace_id": issue.workspace_id}, + ) + return Response( + {"subscriber": str(user.id), "created": created}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="delete_work_item_subscriber", + summary="Unsubscribe a user from a work item", + description="Remove a user's subscription to a work item's notifications.", + tags=["Work Items"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 204: OpenApiResponse(description="Subscriber removed"), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, issue_id, subscriber_id): + subscription = IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber_id=subscriber_id, + ).first() + if subscription is None: + return Response({"error": "Subscription does not exist"}, status=status.HTTP_404_NOT_FOUND) + subscription.delete() + return Response(status=status.HTTP_204_NO_CONTENT)