ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime канал карточек задач

This commit is contained in:
DCCONSTRUCTIONS
2026-04-29 11:41:58 +03:00
parent 83c61a85b4
commit b2a710a7ec
13 changed files with 491 additions and 9 deletions
+8 -1
View File
@@ -7,6 +7,13 @@
import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
import { IssueStreamController } from "./issue-stream.controller";
import { PdfExportController } from "./pdf-export.controller";
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
export const CONTROLLERS = [
CollaborationController,
DocumentController,
HealthController,
IssueStreamController,
PdfExportController,
];
@@ -0,0 +1,139 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Request } from "express";
import type Redis from "ioredis";
import type { WebSocket as WSSocket } from "ws";
// plane imports
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
import { logger } from "@plane/logger";
// redis
import { redisManager } from "@/redis";
// services
import { ProjectMemberService } from "@/services/project-member.service";
import { UserService } from "@/services/user.service";
const ISSUE_EVENT_CHANNEL_PREFIX = "plane:issue-events:project";
const HEARTBEAT_INTERVAL_MS = 25_000;
type TIssueRealtimeEvent = {
event_id?: string;
type?: string;
project_id?: string;
};
const getQueryValue = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(payload));
};
@Controller("/issues")
export class IssueStreamController {
[key: string]: unknown;
@WSDecorator("/stream")
handleConnection(ws: WSSocket, req: Request) {
void this.handleIssueStream(ws, req);
}
private async handleIssueStream(ws: WSSocket, req: Request) {
const workspaceSlug = getQueryValue(req.query.workspaceSlug);
const projectId = getQueryValue(req.query.projectId);
const cookie = req.headers.cookie?.toString();
if (!workspaceSlug || !projectId || !cookie) {
ws.close(1008, "Missing issue stream credentials");
return;
}
let subscriber: Redis | undefined;
let heartbeat: NodeJS.Timeout | undefined;
const cleanup = async () => {
if (heartbeat) clearInterval(heartbeat);
if (subscriber) {
try {
await subscriber.unsubscribe();
subscriber.disconnect();
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: Redis cleanup failed:", error);
}
}
};
try {
const userService = new UserService();
const projectMemberService = new ProjectMemberService();
const user = await userService.currentUser(cookie);
await projectMemberService.currentProjectMember(cookie, workspaceSlug, projectId);
const redisClient = redisManager.getClient();
if (!redisClient) {
ws.close(1011, "Issue stream unavailable");
return;
}
const channel = `${ISSUE_EVENT_CHANNEL_PREFIX}:${projectId}`;
subscriber = redisClient.duplicate({ lazyConnect: true });
await subscriber.connect();
await subscriber.subscribe(channel);
subscriber.on("message", (_channel, message) => {
try {
const event = JSON.parse(message) as TIssueRealtimeEvent;
if (event.project_id !== projectId || !event.type?.startsWith("issue.")) return;
sendJson(ws, event as Record<string, unknown>);
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: Failed to forward issue event:", error);
}
});
subscriber.on("error", (error) => {
logger.error("ISSUE_STREAM_CONTROLLER: Redis subscriber error:", error);
ws.close(1011, "Issue stream subscriber failed");
});
heartbeat = setInterval(() => {
sendJson(ws, { type: "issue.stream.ping", server_ts: new Date().toISOString() });
}, HEARTBEAT_INTERVAL_MS);
sendJson(ws, {
type: "issue.stream.ready",
project_id: projectId,
user_id: user.id,
});
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket authentication failed:", error);
ws.close(1008, "Issue stream authentication failed");
await cleanup();
return;
}
ws.on("message", (message) => {
try {
const payload = JSON.parse(message.toString()) as { type?: string };
if (payload.type === "issue.stream.pong") return;
} catch {
// Client messages are optional for this stream.
}
});
ws.on("close", () => {
void cleanup();
});
ws.on("error", (error: Error) => {
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket connection error:", error);
ws.close(1011, "Issue stream connection failed");
void cleanup();
});
}
}
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// services
import { APIService } from "@/services/api.service";
export class ProjectMemberService extends APIService {
async currentProjectMember(cookie: string, workspaceSlug: string, projectId: string) {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`, {
headers: {
Cookie: cookie,
},
}).then((response) => response?.data);
}
}