Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ LIVE_BASE_PATH="/live"

LIVE_SERVER_SECRET_KEY="secret-key"

# Internal API key for server-to-server broadcast endpoints (must match the live server's LIVE_INTERNAL_API_KEY)
LIVE_INTERNAL_API_KEY=""

# Hard delete files after days
HARD_DELETE_AFTER_DAYS=60

Expand Down
9 changes: 9 additions & 0 deletions apps/api/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
issue_queryset_grouper,
)
from plane.utils.host import base_host
from plane.utils.issue_events import broadcast_issue_event
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
Expand Down Expand Up @@ -791,6 +792,14 @@ def delete(self, request, slug, project_id):
# Finally, delete the issues themselves
issues.delete()

# Broadcast the bulk deletion to connected clients via the live server
broadcast_issue_event(
project_id=project_id,
issue_id=issue_ids[0],
type="issue.deleted_bulk",
actor_id=str(request.user.id),
)

return Response(
{"message": f"{total_issues} issues were deleted"},
status=status.HTTP_200_OK,
Expand Down
9 changes: 9 additions & 0 deletions apps/api/plane/bgtasks/issue_activities_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.utils.issue_events import broadcast_issue_event
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid

Expand Down Expand Up @@ -1524,6 +1525,14 @@ def issue_activity(
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id

# Broadcast the change to connected clients via the live server
broadcast_issue_event(
project_id=project_id,
issue_id=issue_id,
type=type,
actor_id=actor_id,
)

if issue_id is not None:
if origin:
ri = redis_instance()
Expand Down
3 changes: 3 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,9 @@

LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None

# Internal API key for server-to-server calls to the live server
LIVE_INTERNAL_API_KEY = os.environ.get("LIVE_INTERNAL_API_KEY", "")

# WEB URL
WEB_URL = os.environ.get("WEB_URL")

Expand Down
44 changes: 44 additions & 0 deletions apps/api/plane/utils/issue_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

# Python imports
import logging

# Third party imports
import requests

# Django imports
from django.conf import settings

# Module imports
from plane.utils.url import normalize_url_path

logger = logging.getLogger("plane.worker")


def broadcast_issue_event(project_id, issue_id=None, type=None, actor_id=None):
"""
Fire-and-forget broadcast of an issue change event to the live server,
which relays it to all connected clients of the project over websocket.
Disabled when LIVE_URL or LIVE_INTERNAL_API_KEY is not configured.
"""
live_url = getattr(settings, "LIVE_URL", None)
internal_api_key = getattr(settings, "LIVE_INTERNAL_API_KEY", "")
if not live_url or not internal_api_key:
return

try:
requests.post(
normalize_url_path(f"{live_url}/broadcasts/issue-events"),
json={
"project_id": str(project_id),
"issue_id": str(issue_id) if issue_id else None,
"type": type,
"actor_id": str(actor_id) if actor_id else None,
},
headers={"x-internal-api-key": internal_api_key},
timeout=2,
)
except requests.RequestException as e:
logger.warning(f"Failed to broadcast issue event for project {project_id}: {e}")
3 changes: 3 additions & 0 deletions apps/live/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ LIVE_BASE_PATH="/live"

LIVE_SERVER_SECRET_KEY="secret-key"

# Internal API key for server-to-server broadcast endpoints (must match the API server's LIVE_INTERNAL_API_KEY)
LIVE_INTERNAL_API_KEY=""

# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead.
REDIS_PORT=6379
REDIS_HOST=localhost
Expand Down
85 changes: 85 additions & 0 deletions apps/live/src/controllers/broadcast.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import type { Hocuspocus } from "@hocuspocus/server";
import type { Request, Response } from "express";
import { z } from "zod";
// plane imports
import { Controller, Post } from "@plane/decorators";
import { logger } from "@plane/logger";
// env
import { env } from "@/env";
// extensions
import { Redis } from "@/extensions/redis";

const issueEventSchema = z.object({
project_id: z.string().min(1, "project_id is required"),
issue_id: z.string().nullish(),
type: z.string().nullish(),
actor_id: z.string().nullish(),
});

@Controller("/broadcasts")
export class BroadcastController {
[key: string]: unknown;
private readonly hocuspocusServer: Hocuspocus;

constructor(hocuspocusServer: Hocuspocus) {
this.hocuspocusServer = hocuspocusServer;
}

@Post("/issue-events")
async broadcastIssueEvent(req: Request, res: Response) {
// verify the internal api key for server-to-server calls
const internalApiKey = req.headers["x-internal-api-key"];
if (!env.LIVE_INTERNAL_API_KEY || internalApiKey !== env.LIVE_INTERNAL_API_KEY) {
return res.status(401).json({
message: "Unauthorized",
});
}

// validate request body
const parsedBody = issueEventSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
message: "project_id is required",
});
}

const { project_id, issue_id, type, actor_id } = parsedBody.data;
const documentName = `issue-events:${project_id}`;
const payload = JSON.stringify({
event: "issue_changed",
data: {
project_id,
issue_id: issue_id ?? null,
type: type ?? null,
actor_id: actor_id ?? null,
},
});

const redisExtension = this.hocuspocusServer.configuration.extensions.find((ext) => ext instanceof Redis);
if (!redisExtension) {
logger.error("BROADCAST_CONTROLLER: Redis extension not found");
return res.status(500).json({
message: "Broadcast infrastructure unavailable",
});
}

try {
const receivers = await redisExtension.broadcastToDocument(documentName, payload);
return res.status(200).json({
message: "Broadcasted",
receivers,
});
} catch (error) {
logger.error(`BROADCAST_CONTROLLER: Error broadcasting to ${documentName}:`, error);
return res.status(500).json({
message: "Internal server error",
});
}
}
}
9 changes: 8 additions & 1 deletion apps/live/src/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@
* See the LICENSE file for details.
*/

import { BroadcastController } from "./broadcast.controller";
import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
import { PdfExportController } from "./pdf-export.controller";

export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
export const CONTROLLERS = [
BroadcastController,
CollaborationController,
DocumentController,
HealthController,
PdfExportController,
];
2 changes: 2 additions & 0 deletions apps/live/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const envSchema = z.object({
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
// secret
LIVE_SERVER_SECRET_KEY: z.string(),
// internal api key for server-to-server broadcast endpoints
LIVE_INTERNAL_API_KEY: z.string().default(""),
// Redis configuration
REDIS_HOST: z.string().optional(),
REDIS_PORT: z.string().default("6379").transform(Number),
Expand Down
11 changes: 11 additions & 0 deletions apps/live/src/extensions/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database";
import * as Y from "yjs";
// plane imports
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
Expand All @@ -23,7 +24,13 @@ import { broadcastError } from "@/utils/broadcast-error";
// force close utility
import { forceCloseDocumentAcrossServers } from "./force-close-handler";

const ISSUE_EVENTS_DOCUMENT_PREFIX = "issue-events:";

const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
// issue-events documents are ephemeral broadcast channels without any persisted content
if (pageId.startsWith(ISSUE_EVENTS_DOCUMENT_PREFIX)) {
return Y.encodeStateAsUpdate(new Y.Doc());
}
try {
const service = getPageService(context.documentType, context);
// fetch details
Expand Down Expand Up @@ -75,6 +82,10 @@ const storeDocument = async ({
documentName: pageId,
instance,
}: StorePayloadWithContext) => {
// issue-events documents are ephemeral broadcast channels without any persisted content
if (pageId.startsWith(ISSUE_EVENTS_DOCUMENT_PREFIX)) {
return;
}
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
Expand Down
4 changes: 4 additions & 0 deletions apps/live/src/extensions/title-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { TitleUpdateManager } from "./title-update/title-update-manager";
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
// Documents under this prefix are ephemeral broadcast channels without titles
private readonly ISSUE_EVENTS_DOCUMENT_PREFIX = "issue-events:";
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
Expand All @@ -46,6 +48,7 @@ export class TitleSyncExtension implements Extension {
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
if (documentName.startsWith(this.ISSUE_EVENTS_DOCUMENT_PREFIX)) return;
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
Expand Down Expand Up @@ -79,6 +82,7 @@ export class TitleSyncExtension implements Extension {
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
if (documentName.startsWith(this.ISSUE_EVENTS_DOCUMENT_PREFIX)) return;
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
import { useIssueRealtime } from "@/hooks/use-issue-realtime";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { CalendarLayout } from "../calendar/roots/project-root";
Expand Down Expand Up @@ -53,6 +54,13 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;

// soft refresh the current issues when another actor changes them
useIssueRealtime(projectId, () => {
if (workspaceSlug && projectId) {
issues?.fetchIssuesWithExistingPagination(workspaceSlug, projectId, undefined);
}
});

useSWR(
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
async () => {
Expand Down
Loading