mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Merge pull request #73 from Gimanh/feat/messaging-integrations
feat: slack and tg integration
This commit is contained in:
+10
-1
@@ -51,7 +51,16 @@ export default class App {
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
this.app.use(express.urlencoded({
|
||||
extended: true,
|
||||
verify: (req: any, _res, buf) => {
|
||||
// Slack sends slash commands / interactivity as urlencoded; keep the raw body
|
||||
// for HMAC signature verification (VerifySlackRequest).
|
||||
if (req.url?.includes('/messaging/slack/')) {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(appUserMiddleware);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
|
||||
import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTrackingDispatcher';
|
||||
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
|
||||
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
|
||||
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
@@ -14,6 +15,7 @@ const dispatchers: Dispatcher[] = [
|
||||
new TimeTrackingDispatcher(),
|
||||
new SprintsDispatcher(),
|
||||
new RecurrenceDispatcher(),
|
||||
new MessagingDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
|
||||
@@ -655,5 +655,54 @@
|
||||
"Backfill: for every personal organization without an Inbox, inserts one tasks.goals row (name='Inbox', is_inbox=true, owner=the org owner, organization_id=the org). The existing AFTER INSERT triggers fully provision it like any project — default kanban statuses, the owner added to collaboration, and the default editor/executor roles with their permissions. Idempotent — skips orgs that already have an Inbox.",
|
||||
"Partial unique index goals_one_inbox_per_org_uidx ON tasks.goals (organization_id) WHERE is_inbox — enforces at most one Inbox per organization at the DB level, hardening the check-then-insert against concurrent races."
|
||||
]
|
||||
},
|
||||
"51": {
|
||||
"version": "1.56.0",
|
||||
"name": "Messaging integrations",
|
||||
"releaseDate": "20260701",
|
||||
"scripts": [
|
||||
"/1.56.0/0.create-messaging-tables.sql",
|
||||
"/1.56.0/1.alter-messaging-add-events.sql"
|
||||
],
|
||||
"description": [
|
||||
"Messaging integrations module (Slack / Telegram)",
|
||||
"Outbound delivery connections for personal and project/org owners",
|
||||
"Pending link tokens for binding + user identity map",
|
||||
"Per-connection event subscription (which events to deliver)"
|
||||
]
|
||||
},
|
||||
"52": {
|
||||
"version": "1.57.0",
|
||||
"name": "Unique project membership",
|
||||
"releaseDate": "20260702",
|
||||
"scripts": [
|
||||
"/1.57.0/0.unique-users-to-goals.sql"
|
||||
],
|
||||
"description": [
|
||||
"Deduplicate collaboration.users_to_goals rows",
|
||||
"Unique index on (user_id, goal_id) — one membership per user per project"
|
||||
]
|
||||
},
|
||||
"53": {
|
||||
"version": "1.58.0",
|
||||
"name": "Messaging channel post-content flag",
|
||||
"releaseDate": "20260702",
|
||||
"scripts": [
|
||||
"/1.58.0/0.alter-messaging-add-post-content.sql"
|
||||
],
|
||||
"description": [
|
||||
"Per-connection opt-out for posting task description to project channels"
|
||||
]
|
||||
},
|
||||
"54": {
|
||||
"version": "1.59.0",
|
||||
"name": "Messaging identity workspace scoping",
|
||||
"releaseDate": "20260705",
|
||||
"scripts": [
|
||||
"/1.59.0/0.alter-messaging-identity-add-team.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Messaging integrations (Slack / Telegram): outbound delivery targets, pending
|
||||
-- binding tokens, and the map between a TaskView user and their external account.
|
||||
-- See promo/integrations.md for the full design.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.messaging_connections (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
owner_type VARCHAR(20) NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
target_chat_id VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
external_team_id VARCHAR(255),
|
||||
access_token_encrypted VARCHAR,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One delivery target per (provider, owner, chat) — re-connecting the same chat updates instead of duplicating.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS messaging_connection_unique
|
||||
ON tasks.messaging_connections (provider, owner_type, owner_id, target_chat_id);
|
||||
|
||||
-- Pending binding intent, redeemed from the messenger. Owner is polymorphic
|
||||
-- (user / project / organization); permission to bind is checked when the token is minted.
|
||||
CREATE TABLE IF NOT EXISTS tasks.messaging_link_tokens (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
token VARCHAR(128) NOT NULL UNIQUE,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
owner_type VARCHAR(20) NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.messaging_identity_map (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
external_user_id VARCHAR(255),
|
||||
linked_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One identity per provider per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS messaging_identity_unique
|
||||
ON tasks.messaging_identity_map (user_id, provider);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Per-connection event subscription: which events this messaging connection delivers.
|
||||
-- Default covers the common task events; users opt into sprint events in the UI.
|
||||
ALTER TABLE tasks.messaging_connections
|
||||
ADD COLUMN IF NOT EXISTS events VARCHAR[] NOT NULL
|
||||
DEFAULT ARRAY['task.created','task.assigned','task.statusChanged','task.completed']::VARCHAR[];
|
||||
@@ -0,0 +1,15 @@
|
||||
-- A user is either a member of a project or not — there is no meaning to two
|
||||
-- membership rows for the same (user_id, goal_id). Roles live in a separate table
|
||||
-- (collaboration.users_to_roles), so multi-role membership does not need duplicate rows.
|
||||
-- Dedupe any existing duplicates (the table has no PK, so key off ctid), then enforce
|
||||
-- uniqueness. Insert paths use ON CONFLICT DO NOTHING so re-adding a member is idempotent.
|
||||
|
||||
DELETE FROM collaboration.users_to_goals
|
||||
WHERE ctid NOT IN (
|
||||
SELECT MIN(ctid)
|
||||
FROM collaboration.users_to_goals
|
||||
GROUP BY user_id, goal_id
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_to_goals_user_goal_uidx
|
||||
ON collaboration.users_to_goals (user_id, goal_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Project channels: opt-out flag for including the RBAC-gated task description in the
|
||||
-- channel message. Default TRUE (channel is a deliberate broadcast). Personal DMs are
|
||||
-- unaffected — they always gate the description per recipient (COMPONENT_CAN_WATCH_CONTENT).
|
||||
ALTER TABLE tasks.messaging_connections
|
||||
ADD COLUMN IF NOT EXISTS post_content BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks.messaging_identity_map ADD COLUMN IF NOT EXISTS external_team_id VARCHAR(255);
|
||||
@@ -6,6 +6,7 @@ import GraphRoutes from '../tv-modules/graph/GraphRoutes';
|
||||
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
|
||||
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
|
||||
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
|
||||
import MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
|
||||
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
|
||||
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
|
||||
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
@@ -39,6 +40,7 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
'/module/messaging': MessagingRoutes,
|
||||
'/module/api-tokens': ApiTokensRoutes,
|
||||
'/module/sessions': SessionsRoutes,
|
||||
'/module/organizations': OrganizationRoutes,
|
||||
|
||||
@@ -74,7 +74,7 @@ export class CollaborationRepository {
|
||||
}
|
||||
|
||||
await this.db
|
||||
.query(`insert into collaboration.users_to_goals (goal_id, user_id) values ($1, $2)`, [goalId, userId])
|
||||
.query(`insert into collaboration.users_to_goals (goal_id, user_id) values ($1, $2) on conflict (user_id, goal_id) do nothing`, [goalId, userId])
|
||||
.catch(logError);
|
||||
|
||||
return userId ?? false;
|
||||
@@ -220,7 +220,7 @@ export class CollaborationRepository {
|
||||
await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
userId: userId,
|
||||
goalId: args.goalId,
|
||||
});
|
||||
}).onConflictDoNothing();
|
||||
|
||||
return user;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { MessagingManager } from './MessagingManager';
|
||||
import { SlackInboundManager } from './SlackInboundManager';
|
||||
import { SLACK_OAUTH_NONCE_COOKIE } from './config';
|
||||
import {
|
||||
MessagingArkTypeConnectLink,
|
||||
MessagingArkTypeById,
|
||||
MessagingArkTypeToggle,
|
||||
MessagingArkTypeUpdateEvents,
|
||||
MessagingArkTypeProviderParam,
|
||||
MessagingArkTypeProjectToggle,
|
||||
MessagingArkTypeProjectDelete,
|
||||
MessagingArkTypeProjectPostContent,
|
||||
} from './types';
|
||||
import { getAuthorizedGoalId } from './middlewares/ProjectMessagingPermission';
|
||||
|
||||
export class MessagingController {
|
||||
private readonly manager = new MessagingManager();
|
||||
private readonly slackInbound = new SlackInboundManager();
|
||||
|
||||
// Slash command (/task). Signature already verified by VerifySlackRequest.
|
||||
slackCommands = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const reply = await this.slackInbound.handleSlashCommand(req.body ?? {});
|
||||
return res.json(reply);
|
||||
} catch (err) {
|
||||
$logger.error(err, '[Messaging/Slack] slash command failed');
|
||||
return res.json({ response_type: 'ephemeral', text: 'Something went wrong handling that command.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Button clicks and modal submissions. Payload is a JSON string in the `payload` field.
|
||||
slackInteractivity = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = (req.body as { payload?: unknown })?.payload;
|
||||
const interaction = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
const result = await this.slackInbound.handleInteraction(interaction ?? {});
|
||||
return res.json(result ?? {});
|
||||
} catch (err) {
|
||||
$logger.error(err, '[Messaging/Slack] interactivity failed');
|
||||
return res.status(200).end();
|
||||
}
|
||||
};
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.fetchPersonalConnections(userId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
connectLink = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeConnectLink({ ...req.params, ...req.query });
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.createPersonalConnectLink(userId, data.provider);
|
||||
if (!result) return res.status(503).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
toggle = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeToggle(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.togglePersonal(data.id, userId, data.isActive);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.deletePersonal(data.id, userId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
updateEvents = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeUpdateEvents(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.updatePersonalEvents(data.id, userId, data.events);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchProject = async (_req: Request, res: Response) => {
|
||||
const result = await this.manager.fetchProjectConnections(getAuthorizedGoalId(res));
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
projectConnectLink = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProviderParam(req.params);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.createProjectConnectLink(getAuthorizedGoalId(res), userId, data.provider);
|
||||
if (!result) return res.status(503).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
toggleProject = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProjectToggle(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.toggleProject(data.id, getAuthorizedGoalId(res), data.isActive);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
deleteProject = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProjectDelete(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.deleteProject(data.id, getAuthorizedGoalId(res));
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
updateProjectEvents = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeUpdateEvents(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.updateProjectEvents(data.id, getAuthorizedGoalId(res), data.events);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
updateProjectPostContent = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProjectPostContent(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.updateProjectPostContent(data.id, getAuthorizedGoalId(res), data.postContent);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
// Slack uses OAuth (browser redirect), so the user's JWT is passed as ?token and
|
||||
// validated manually — a full-page redirect can't carry the API Authorization header.
|
||||
slackOAuthStart = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.query.token as string;
|
||||
if (!token) return res.status(401).send('token is required');
|
||||
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
const userId = userPayload?.userData?.id;
|
||||
if (!userId) return res.status(401).send('Invalid token');
|
||||
|
||||
if (!this.manager.getProvider('slack')?.isConfigured()) {
|
||||
return res.status(503).send('Slack is not configured on the server (set SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_CALLBACK_URL)');
|
||||
}
|
||||
|
||||
const scope: 'user' | 'project' = req.query.scope === 'project' ? 'project' : 'user';
|
||||
let ownerId = userId;
|
||||
|
||||
if (scope === 'project') {
|
||||
const goalId = Number(req.query.goalId);
|
||||
if (!goalId || Number.isNaN(goalId)) return res.status(400).send('goalId is required');
|
||||
const checker = await new AppUser(userPayload).permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) return res.status(403).end();
|
||||
ownerId = goalId;
|
||||
}
|
||||
|
||||
const returnPath = this.safeReturnPath(req.query.returnPath);
|
||||
const start = await this.manager.startOAuthConnect('slack', { ownerType: scope, ownerId, userId, returnPath });
|
||||
if (!start) return res.status(503).send('Slack is not configured on the server');
|
||||
// The provider hands back the anti-CSRF nonce cookie to set on this browser.
|
||||
if (start.kind === 'oauth') {
|
||||
res.cookie(start.setCookie.name, start.setCookie.value, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: start.setCookie.maxAgeMs,
|
||||
});
|
||||
}
|
||||
return res.redirect(start.url);
|
||||
} catch (err) {
|
||||
$logger.error(err, '[Messaging/Slack] Failed to start OAuth');
|
||||
return res.status(500).send('Failed to start Slack OAuth');
|
||||
}
|
||||
};
|
||||
|
||||
slackOAuthCallback = async (req: Request, res: Response) => {
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
const nonce = req.cookies?.[SLACK_OAUTH_NONCE_COOKIE] as string | undefined;
|
||||
res.clearCookie(SLACK_OAUTH_NONCE_COOKIE);
|
||||
if (!code || !state) return res.redirect(`${process.env.APP_URL}?messaging=error`);
|
||||
try {
|
||||
const result = await this.manager.handleInbound('slack', { source: 'oauth-callback', payload: { code, state }, cookie: nonce });
|
||||
return res.redirect(`${process.env.APP_URL}${this.safeReturnPath(result?.redirect)}?messaging=connected`);
|
||||
} catch (err) {
|
||||
$logger.error({ error: err instanceof Error ? err.message : String(err), hasNonce: !!nonce }, '[Messaging/Slack] OAuth callback failed');
|
||||
return res.redirect(`${process.env.APP_URL}?messaging=error`);
|
||||
}
|
||||
};
|
||||
|
||||
private safeReturnPath(value: unknown): string {
|
||||
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')
|
||||
? value
|
||||
: '';
|
||||
}
|
||||
|
||||
telegramWebhook = async (req: Request, res: Response) => {
|
||||
res.status(200).end();
|
||||
await this.manager.handleInbound('telegram', { source: 'webhook', payload: req.body });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { SprintsSchema, TasksSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { GoalPermissionsRepository } from '../../core/GoalPermissionsRepository';
|
||||
import { GoalPermissionsChecker } from '../../core/GoalPermissionsChecker';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { MessagingRepository } from './MessagingRepository';
|
||||
import { MessagingManager } from './MessagingManager';
|
||||
import { buildMessagingMessage } from './messages';
|
||||
import { buildTaskDeepLink } from './utils';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { MessagingDeliverJobData, MessagingEvent, MessagingProviderId } from './types';
|
||||
import type { MessagingDispatchArgs, MessagingRecipient, MessagingTaskContext } from './types.internal';
|
||||
|
||||
const MESSAGING_DELIVER_JOB = 'messaging-deliver';
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
export class MessagingDispatcher implements Dispatcher {
|
||||
private readonly repository = new MessagingRepository();
|
||||
private readonly manager = new MessagingManager();
|
||||
private readonly permissionsRepo = new GoalPermissionsRepository();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.onTaskCreated(data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.onTaskAssigned(data));
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
eventBus.on('task.assignedToSprint', (data) => this.onTaskAddedToSprint(data));
|
||||
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
|
||||
|
||||
eventBus.on('sprint.created', (data) => this.onSprintObj('sprint.created', data));
|
||||
eventBus.on('sprint.updated', (data) => this.onSprintObj('sprint.updated', data));
|
||||
eventBus.on('sprint.activated', (data) => this.onSprint('sprint.started', data));
|
||||
eventBus.on('sprint.reviewStarted', (data) => this.onSprint('sprint.reviewStarted', data));
|
||||
eventBus.on('sprint.completed', (data) => this.onSprint('sprint.completed', data));
|
||||
eventBus.on('sprint.paused', (data) => this.onSprint('sprint.paused', data));
|
||||
eventBus.on('sprint.resumed', (data) => this.onSprint('sprint.resumed', data));
|
||||
eventBus.on('sprint.deleted', (data) => this.onSprint('sprint.deleted', data));
|
||||
|
||||
eventBus.on('collaboration.userAdded', (data) => this.onMember('member.added', data.goalId, data.email, data.initiatorId));
|
||||
eventBus.on('collaboration.userRemoved', (data) => this.onMemberByCollab('member.removed', data.goalId, data.collaborationUserId, data.initiatorId));
|
||||
eventBus.on('collaboration.rolesChanged', (data) => this.onMemberByCollab('member.rolesChanged', data.goalId, data.collaborationUserId, data.initiatorId));
|
||||
|
||||
eventBus.on('time-entry.started', (data) => this.onTimeEntry('time.started', data.goalId, data.taskId, data.userId));
|
||||
eventBus.on('time-entry.stopped', (data) => this.onTimeEntry('time.stopped', data.goalId, data.taskId, data.userId));
|
||||
eventBus.on('time-entry.created', (data) => this.onTimeEntry('time.logged', data.entry.goalId, data.entry.taskId, data.initiatorId));
|
||||
eventBus.on('time-entry.updated', (data) => this.onTimeEntry('time.updated', data.entry.goalId, data.entry.taskId, data.initiatorId));
|
||||
eventBus.on('time-entry.deleted', (data) => this.onTimeEntry('time.deleted', data.goalId, data.taskId, data.initiatorId));
|
||||
|
||||
eventBus.on('recurrence.created', (data) => this.onRecurrence('recurrence.created', data.rule.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.updated', (data) => this.onRecurrence('recurrence.updated', data.rule.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.paused', (data) => this.onRecurrence('recurrence.paused', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.resumed', (data) => this.onRecurrence('recurrence.resumed', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.ended', (data) => this.onRecurrence('recurrence.ended', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.deleted', (data) => this.onRecurrence('recurrence.deleted', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.instanceSkipped', (data) => this.onRecurrence('recurrence.skipped', data.goalId, data.initiatorId));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(MESSAGING_DELIVER_JOB);
|
||||
await boss.work<MessagingDeliverJobData>(MESSAGING_DELIVER_JOB, async ([job]) => {
|
||||
await this.deliverJob(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
|
||||
// A brand-new task usually has no assignees yet, so target project members.
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.task.goalId);
|
||||
await this.dispatch({
|
||||
event: 'task.created',
|
||||
goalId: data.task.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(data.task)
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskAssigned(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
if (data.userIds.length === 0) return;
|
||||
const task = await this.fetchTask(data.taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Keep only collab ids still assigned AND still project members, then resolve to auth recipients.
|
||||
const currentAssignees = new Set(await this.repository.fetchCurrentAssigneeCollabIds(task.id));
|
||||
const stillAssigned = data.userIds.filter(id => currentAssignees.has(id));
|
||||
const memberCollabIds = await this.repository.filterCollabIdsInGoal(stillAssigned, task.goalId);
|
||||
if (memberCollabIds.length === 0) return;
|
||||
const recipients = await this.repository.resolveCollabIdsToRecipients(memberCollabIds);
|
||||
|
||||
await this.dispatch({
|
||||
event: 'task.assigned',
|
||||
goalId: task.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(task)
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
const changes = data.changes ?? {};
|
||||
let event: MessagingEvent;
|
||||
let titleOverride: string | undefined;
|
||||
if ('complete' in changes && data.task.complete === true) event = 'task.completed';
|
||||
else if ('complete' in changes && data.task.complete === false) {
|
||||
// Reopen: gated by the same task.completed subscription, but shown as "reopened".
|
||||
event = 'task.completed';
|
||||
titleOverride = '[Task reopened]';
|
||||
} else if ('statusId' in changes) event = 'task.statusChanged';
|
||||
else event = 'task.edited';
|
||||
|
||||
const recipients = await this.taskAssigneeRecipients(data.task.id, data.task.goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId: data.task.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(data.task),
|
||||
titleOverride
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskAddedToSprint(data: AppEvents['task.assignedToSprint']): Promise<void> {
|
||||
if (!data.sprintId) return;
|
||||
const task = await this.fetchTask(data.taskId);
|
||||
if (!task) return;
|
||||
const recipients = await this.taskAssigneeRecipients(data.taskId, data.goalId);
|
||||
await this.dispatch({
|
||||
event: 'task.addedToSprint',
|
||||
goalId: data.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(task)
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
|
||||
await this.dispatch({
|
||||
event: 'task.deleted',
|
||||
goalId: data.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
body: `#${data.taskId}`
|
||||
});
|
||||
}
|
||||
|
||||
private async onSprintObj(event: MessagingEvent, data: { sprint: { goalId: number; name: string }; initiatorId: number }): Promise<void> {
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.sprint.goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId: data.sprint.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
body: data.sprint.name
|
||||
});
|
||||
}
|
||||
|
||||
private async onSprint(event: MessagingEvent, data: { sprintId: number; goalId: number; initiatorId: number | null }): Promise<void> {
|
||||
const name = await this.fetchSprintName(data.sprintId);
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId: data.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
body: name
|
||||
});
|
||||
}
|
||||
|
||||
private async onMember(event: MessagingEvent, goalId: number, body: string, initiatorId: number): Promise<void> {
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId,
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
private async onMemberByCollab(event: MessagingEvent, goalId: number, collabId: number, initiatorId: number): Promise<void> {
|
||||
const email = await this.repository.fetchCollabEmail(collabId);
|
||||
await this.onMember(event, goalId, email ?? '', initiatorId);
|
||||
}
|
||||
|
||||
private async onTimeEntry(event: MessagingEvent, goalId: number, taskId: number, initiatorId: number | null): Promise<void> {
|
||||
const task = await this.fetchTask(taskId);
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
|
||||
// Time entries reference a task → gate the task description like task events.
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId,
|
||||
task: task ? this.taskCtx(task) : undefined,
|
||||
body: task ? undefined : `#${taskId}`
|
||||
});
|
||||
}
|
||||
|
||||
private async onRecurrence(event: MessagingEvent, goalId: number, initiatorId: number): Promise<void> {
|
||||
// Recurring-rule template content is not shown (avoids leaking gated task content); title only.
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
|
||||
await this.dispatch({ event, goalId, personalRecipients: recipients, initiatorId, body: '' });
|
||||
}
|
||||
|
||||
private async dispatch(args: MessagingDispatchArgs): Promise<void> {
|
||||
// No initiator exclusion: messaging is an explicit opt-in feed — if you
|
||||
// subscribed to an event you receive it, even for your own actions.
|
||||
const url = args.task ? await this.buildTaskUrl(args.goalId, args.task.id, args.task.goalListId) : undefined;
|
||||
const projectName = await this.repository.fetchGoalName(args.goalId);
|
||||
// Assignee emails are RBAC-gated content. Compute once, then include per recipient
|
||||
// only when they may see assignees (TASKS_CAN_WATCH_ASSIGNED_USERS) — same treatment
|
||||
// as the description, which is gated by COMPONENT_CAN_WATCH_CONTENT.
|
||||
const footerText = args.task ? await this.assigneesFooter(args.task.id) : undefined;
|
||||
|
||||
let personalCount = 0;
|
||||
for (const r of args.personalRecipients) {
|
||||
const checker = args.task ? await this.checkerForRecipient(args.goalId, r) : null;
|
||||
const body = this.bodyForRecipient(args, checker);
|
||||
const footer = footerText && checker?.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS) ? footerText : undefined;
|
||||
const message = buildMessagingMessage({
|
||||
event: args.event,
|
||||
audience: 'personal',
|
||||
body,
|
||||
url,
|
||||
taskId: args.task?.id,
|
||||
projectName,
|
||||
footer,
|
||||
completed: args.task?.complete,
|
||||
titleOverride: args.titleOverride
|
||||
});
|
||||
const connections = await this.subscribed('user', r.userId, args.event);
|
||||
personalCount += connections.length;
|
||||
await this.enqueueAll(connections, message);
|
||||
}
|
||||
|
||||
// Project channel: a shared channel's membership doesn't map to TaskView content
|
||||
// permission. By default the description AND assignees ARE posted (a channel is a
|
||||
// deliberate broadcast); a connection can opt out via post_content — then task events
|
||||
// get title + link only (no description, no assignee emails). Non-task bodies
|
||||
// (sprint name, …) are not RBAC-gated content.
|
||||
const projectConnections = await this.subscribed('project', args.goalId, args.event);
|
||||
for (const connection of projectConnections) {
|
||||
const projectBody = args.task
|
||||
? (connection.postContent ? (args.task.description ?? `#${args.task.id}`) : '')
|
||||
: (args.body ?? '');
|
||||
const projectFooter = connection.postContent ? footerText : undefined;
|
||||
// A content-hidden channel gets title + link only — no description, no assignee footer,
|
||||
// and no action buttons (their modals would expose members / task state).
|
||||
const projectMessage = buildMessagingMessage({
|
||||
event: args.event,
|
||||
audience: 'project',
|
||||
body: projectBody,
|
||||
url,
|
||||
taskId: args.task?.id,
|
||||
projectName,
|
||||
footer: projectFooter,
|
||||
completed: args.task?.complete,
|
||||
titleOverride: args.titleOverride,
|
||||
actions: connection.postContent
|
||||
});
|
||||
await this.enqueueAll([connection], projectMessage);
|
||||
}
|
||||
|
||||
$logger.info(
|
||||
{ event: args.event, goalId: args.goalId, members: args.personalRecipients.length, personalConns: personalCount, projectConns: projectConnections.length },
|
||||
'[Messaging] dispatch',
|
||||
);
|
||||
}
|
||||
|
||||
private bodyForRecipient(args: MessagingDispatchArgs, checker: GoalPermissionsChecker | null): string {
|
||||
if (!args.task) return args.body ?? '';
|
||||
const canWatch = checker?.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) ?? false;
|
||||
return canWatch ? (args.task.description ?? `#${args.task.id}`) : `#${args.task.id}`;
|
||||
}
|
||||
|
||||
/** Per-recipient permission checker for the goal; fail-closed (empty) on lookup error. */
|
||||
private async checkerForRecipient(goalId: number, recipient: MessagingRecipient): Promise<GoalPermissionsChecker> {
|
||||
const permissions = await this.permissionsRepo
|
||||
.fetchPermissionsForGoalByUser({ goalId, userId: recipient.userId, email: recipient.email })
|
||||
.catch((err) => {
|
||||
$logger.error(err, `[Messaging] permission check failed for user=${recipient.userId}`);
|
||||
return [];
|
||||
});
|
||||
return new GoalPermissionsChecker(permissions);
|
||||
}
|
||||
|
||||
private async buildTaskUrl(goalId: number, taskId: number, goalListId: number | null): Promise<string | undefined> {
|
||||
const orgSlug = await this.repository.fetchGoalOrgSlug(goalId);
|
||||
return buildTaskDeepLink(orgSlug, goalId, taskId, goalListId);
|
||||
}
|
||||
|
||||
/** "👤 email1, email2" of the task's current assignees, or undefined if none. */
|
||||
private async assigneesFooter(taskId: number): Promise<string | undefined> {
|
||||
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
|
||||
if (collabIds.length === 0) return undefined;
|
||||
const assignees = await this.repository.resolveCollabIdsToRecipients(collabIds);
|
||||
if (assignees.length === 0) return undefined;
|
||||
return `👤 ${assignees.map((a) => a.email).join(', ')}`;
|
||||
}
|
||||
|
||||
private async subscribed(ownerType: string, ownerId: number, event: MessagingEvent): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
|
||||
const connections = await this.repository.fetchActiveByOwner(ownerType, ownerId);
|
||||
return connections.filter(c => c.events.includes(event));
|
||||
}
|
||||
|
||||
private async taskAssigneeRecipients(taskId: number, goalId: number): Promise<MessagingRecipient[]> {
|
||||
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
|
||||
const memberCollabIds = await this.repository.filterCollabIdsInGoal(collabIds, goalId);
|
||||
return this.repository.resolveCollabIdsToRecipients(memberCollabIds);
|
||||
}
|
||||
|
||||
private taskCtx(task: { id: number; goalListId: number | null; description: string | null; complete: boolean | null }): MessagingTaskContext {
|
||||
return { id: task.id, goalListId: task.goalListId, description: task.description, complete: task.complete === true };
|
||||
}
|
||||
|
||||
private async fetchTask(taskId: number) {
|
||||
const db = Database.getInstance();
|
||||
const rows = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, taskId)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async fetchSprintName(sprintId: number): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
const rows = await db.dbDrizzle.select({ name: SprintsSchema.name }).from(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).limit(1);
|
||||
return rows[0]?.name ?? `#${sprintId}`;
|
||||
}
|
||||
|
||||
private async enqueueAll(connections: MessagingConnectionsSchemaTypeForSelect[], message: ReturnType<typeof buildMessagingMessage>): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
for (const connection of connections) {
|
||||
await boss.send(MESSAGING_DELIVER_JOB, {
|
||||
connectionId: connection.id,
|
||||
provider: connection.provider as MessagingProviderId,
|
||||
chatId: connection.targetChatId,
|
||||
accessTokenEncrypted: connection.accessTokenEncrypted,
|
||||
message,
|
||||
attempt: 1,
|
||||
} satisfies MessagingDeliverJobData);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverJob(data: MessagingDeliverJobData): Promise<void> {
|
||||
const provider = this.manager.getProvider(data.provider);
|
||||
if (!provider || !provider.isConfigured()) return;
|
||||
|
||||
const result = await provider.deliver({
|
||||
chatId: data.chatId,
|
||||
accessToken: data.accessTokenEncrypted ? decrypt(data.accessTokenEncrypted) : null,
|
||||
message: data.message,
|
||||
});
|
||||
|
||||
$logger.info({ connectionId: data.connectionId, provider: data.provider, success: result.success, errorCode: result.errorCode }, '[Messaging] deliver result');
|
||||
|
||||
if (result.success) return;
|
||||
|
||||
if (data.attempt < MAX_ATTEMPTS) {
|
||||
const boss = getJobQueue();
|
||||
const delay = Math.pow(2, data.attempt) * 5;
|
||||
await boss.send(MESSAGING_DELIVER_JOB, { ...data, attempt: data.attempt + 1 }, { startAfter: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.warn(`[Messaging] Delivery failed for connection=${data.connectionId} after ${MAX_ATTEMPTS} attempts`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { MessagingRepository } from './MessagingRepository';
|
||||
import { TelegramProvider } from './providers/telegram.provider';
|
||||
import { SlackProvider } from './providers/slack.provider';
|
||||
import { buildTaskDeepLink } from './utils';
|
||||
import type { MessagingProvider } from './providers/MessagingProvider';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingConnectionForClient, MessagingConnectLinkResult } from './types.internal';
|
||||
import { sanitizeMessagingEvents, type MessagingMessage, type MessagingProviderId } from './types';
|
||||
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export class MessagingManager {
|
||||
public readonly repository = new MessagingRepository();
|
||||
private readonly telegram = new TelegramProvider();
|
||||
private readonly slack = new SlackProvider();
|
||||
private readonly providers: Map<MessagingProviderId, MessagingProvider>;
|
||||
|
||||
constructor() {
|
||||
this.providers = new Map<MessagingProviderId, MessagingProvider>([
|
||||
['telegram', this.telegram],
|
||||
['slack', this.slack],
|
||||
]);
|
||||
}
|
||||
|
||||
getProvider(id: MessagingProviderId): MessagingProvider | undefined {
|
||||
return this.providers.get(id);
|
||||
}
|
||||
|
||||
private async beginConnect(id: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
|
||||
const provider = this.getProvider(id);
|
||||
if (!provider?.isConfigured()) return null;
|
||||
const start = await provider.startConnect(ctx);
|
||||
if (start.kind === 'deep-link') {
|
||||
const row = await this.repository.createLinkToken({
|
||||
token: start.persistToken.token,
|
||||
provider: id,
|
||||
ownerType: ctx.ownerType,
|
||||
ownerId: ctx.ownerId,
|
||||
createdBy: ctx.userId,
|
||||
expiresAt: start.persistToken.expiresAt,
|
||||
});
|
||||
if (!row) return null;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
async createPersonalConnectLink(userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
|
||||
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'user', ownerId: userId, userId }));
|
||||
}
|
||||
|
||||
async createProjectConnectLink(goalId: number, userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
|
||||
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'project', ownerId: goalId, userId }));
|
||||
}
|
||||
|
||||
private toLinkResult(provider: MessagingProviderId, start: ConnectStart | null): MessagingConnectLinkResult | null {
|
||||
if (start?.kind !== 'deep-link') return null;
|
||||
return { provider, url: start.url, token: start.persistToken.token, expiresAt: start.persistToken.expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
async startOAuthConnect(provider: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
|
||||
return this.beginConnect(provider, ctx);
|
||||
}
|
||||
|
||||
async handleInbound(id: MessagingProviderId, raw: InboundRaw): Promise<{ redirect?: string } | null> {
|
||||
const provider = this.getProvider(id);
|
||||
if (!provider) return null;
|
||||
const intent = await provider.parseInbound(raw);
|
||||
if (!intent) return null;
|
||||
|
||||
if (intent.kind === 'createConnection') {
|
||||
await this.repository.createConnection(intent.connection);
|
||||
if (intent.identity) await this.repository.upsertIdentity({ userId: intent.identity.userId, provider: id, externalUserId: intent.identity.externalUserId, externalTeamId: intent.identity.externalTeamId });
|
||||
return { redirect: intent.redirect };
|
||||
}
|
||||
|
||||
if (intent.kind === 'command') {
|
||||
const reply = await this.runCreateTask(id, intent);
|
||||
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: reply });
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.bindByToken(id, provider, intent);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async runCreateTask(id: MessagingProviderId, intent: Extract<InboundIntent, { kind: 'command' }>): Promise<MessagingMessage> {
|
||||
const note = (title: string): MessagingMessage => ({ event: 'task.created', title });
|
||||
|
||||
const userId = await this.repository.findUserIdByExternalId({ provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
|
||||
if (!userId) return note('Link your account in TaskView first (Personal → Connect), then try /task again.');
|
||||
|
||||
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: id, channelId: intent.chatId, externalTeamId: null });
|
||||
if (goalIds.length === 0) return note('This chat is not linked to a TaskView project.');
|
||||
if (goalIds.length > 1) return note('This chat is linked to several projects — create the task in TaskView.');
|
||||
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return note('Could not resolve your TaskView account.');
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
|
||||
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
|
||||
return note('You do not have permission to create tasks in this project.');
|
||||
}
|
||||
|
||||
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description: intent.text });
|
||||
const task = created?.[0];
|
||||
if (!task) return note('Failed to create the task.');
|
||||
|
||||
const orgSlug = await this.repository.fetchGoalOrgSlug(goalIds[0]);
|
||||
const url = buildTaskDeepLink(orgSlug, goalIds[0], task.id, task.goalListId ?? null);
|
||||
return { event: 'task.created', title: '✅ Task created', body: intent.text, url };
|
||||
}
|
||||
|
||||
private async buildAppUser(userId: number): Promise<AppUser | null> {
|
||||
const record = await new AppUser().authManager.repository.fetchUserById(userId);
|
||||
if (!record || record.block !== 0) return null;
|
||||
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
|
||||
}
|
||||
|
||||
private async bindByToken(id: MessagingProviderId, provider: MessagingProvider, intent: Extract<InboundIntent, { kind: 'bindByToken' }>): Promise<void> {
|
||||
const link = await this.repository.findValidLinkToken(id, intent.token);
|
||||
// Silently drop unknown/expired tokens or a token used in the wrong chat kind — no
|
||||
// reply, so the endpoint can't be used as an outbound-message amplifier.
|
||||
if (!link || link.ownerType !== intent.scope) return;
|
||||
|
||||
const connection = await this.repository.createConnection({
|
||||
provider: id,
|
||||
ownerType: link.ownerType,
|
||||
ownerId: link.ownerId,
|
||||
targetChatId: intent.chatId,
|
||||
title: intent.title,
|
||||
externalTeamId: null,
|
||||
accessTokenEncrypted: null,
|
||||
});
|
||||
if (!connection) {
|
||||
$logger.error(`[Messaging] Failed to create ${id} connection for ${link.ownerType}=${link.ownerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Telegram user IDs are globally unique, so there is no workspace to scope by.
|
||||
if (intent.scope === 'user') await this.repository.upsertIdentity({ userId: link.ownerId, provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
|
||||
await this.repository.consumeLinkToken(link.id);
|
||||
|
||||
const text = intent.scope === 'user'
|
||||
? 'Done! Your TaskView account is linked — notifications will be delivered here.'
|
||||
: 'Done! This chat is connected to your TaskView project — project events will be posted here.';
|
||||
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: { event: 'task.created', title: text } });
|
||||
}
|
||||
|
||||
async fetchPersonalConnections(userId: number): Promise<MessagingConnectionForClient[]> {
|
||||
const connections = await this.repository.fetchByOwner('user', userId);
|
||||
return connections.map(c => this.toClient(c));
|
||||
}
|
||||
|
||||
async togglePersonal(id: number, userId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.setActiveOwned({ id, ownerType: 'user', ownerId: userId, isActive });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async deletePersonal(id: number, userId: number): Promise<boolean> {
|
||||
return this.repository.deleteOwned({ id, ownerType: 'user', ownerId: userId });
|
||||
}
|
||||
|
||||
async updatePersonalEvents(id: number, userId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'user', ownerId: userId, events: sanitizeMessagingEvents(events) });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async fetchProjectConnections(goalId: number): Promise<MessagingConnectionForClient[]> {
|
||||
const connections = await this.repository.fetchByOwner('project', goalId);
|
||||
return connections.map(c => this.toClient(c));
|
||||
}
|
||||
|
||||
async toggleProject(id: number, goalId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
|
||||
// goalId was verified against the caller by IsGoalOwnerByGoalId; the WHERE
|
||||
// clause also binds the connection to that goal, so no cross-project mutation.
|
||||
const updated = await this.repository.setActiveOwned({ id, ownerType: 'project', ownerId: goalId, isActive });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async deleteProject(id: number, goalId: number): Promise<boolean> {
|
||||
return this.repository.deleteOwned({ id, ownerType: 'project', ownerId: goalId });
|
||||
}
|
||||
|
||||
async updateProjectEvents(id: number, goalId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'project', ownerId: goalId, events: sanitizeMessagingEvents(events) });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async updateProjectPostContent(id: number, goalId: number, postContent: boolean): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.setPostContentOwned({ id, ownerType: 'project', ownerId: goalId, postContent });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
private toClient(connection: MessagingConnectionsSchemaTypeForSelect): MessagingConnectionForClient {
|
||||
const { accessTokenEncrypted, ...rest } = connection;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { and, eq, gt, inArray, isNull } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
CollaborationUsersSchema,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
GoalsSchema,
|
||||
MessagingConnectionsSchema,
|
||||
MessagingIdentityMapSchema,
|
||||
MessagingLinkTokensSchema,
|
||||
OrganizationsSchema,
|
||||
TasksAssigneeSchema,
|
||||
UsersSchema,
|
||||
type MessagingConnectionsSchemaTypeForSelect,
|
||||
type MessagingLinkTokensSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import { SLACK_WEBHOOK_PREFIX } from './config';
|
||||
import type {
|
||||
MessagingChannelLookup,
|
||||
MessagingConnectionCreate,
|
||||
MessagingIdentityLookup,
|
||||
MessagingIdentityUpsert,
|
||||
MessagingLinkTokenCreate,
|
||||
MessagingOwnedRef,
|
||||
MessagingOwnedToggle,
|
||||
MessagingRecipient,
|
||||
} from './types.internal';
|
||||
|
||||
export class MessagingRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async createConnection(data: MessagingConnectionCreate): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(MessagingConnectionsSchema)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
MessagingConnectionsSchema.provider,
|
||||
MessagingConnectionsSchema.ownerType,
|
||||
MessagingConnectionsSchema.ownerId,
|
||||
MessagingConnectionsSchema.targetChatId,
|
||||
],
|
||||
set: {
|
||||
title: data.title,
|
||||
externalTeamId: data.externalTeamId,
|
||||
accessTokenEncrypted: data.accessTokenEncrypted,
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(eq(MessagingConnectionsSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
|
||||
and(
|
||||
eq(MessagingConnectionsSchema.ownerType, ownerType),
|
||||
eq(MessagingConnectionsSchema.ownerId, ownerId),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchActiveByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
|
||||
and(
|
||||
eq(MessagingConnectionsSchema.ownerType, ownerType),
|
||||
eq(MessagingConnectionsSchema.ownerId, ownerId),
|
||||
eq(MessagingConnectionsSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async setActiveOwned(args: MessagingOwnedToggle): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(MessagingConnectionsSchema)
|
||||
.set({ isActive: args.isActive, updatedAt: new Date() })
|
||||
.where(this.ownedWhere(args))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteOwned(args: MessagingOwnedRef): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(MessagingConnectionsSchema).where(this.ownedWhere(args))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
private ownedWhere(args: MessagingOwnedRef) {
|
||||
return and(
|
||||
eq(MessagingConnectionsSchema.id, args.id),
|
||||
eq(MessagingConnectionsSchema.ownerType, args.ownerType),
|
||||
eq(MessagingConnectionsSchema.ownerId, args.ownerId),
|
||||
);
|
||||
}
|
||||
|
||||
async resolveCollabIdsToRecipients(collabIds: number[]): Promise<MessagingRecipient[]> {
|
||||
if (collabIds.length === 0) return [];
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(inArray(CollaborationUsersSchema.id, collabIds))
|
||||
);
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
async updateEventsOwned(args: MessagingOwnedRef & { events: string[] }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(MessagingConnectionsSchema)
|
||||
.set({ events: args.events, updatedAt: new Date() })
|
||||
.where(this.ownedWhere(args))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async setPostContentOwned(args: MessagingOwnedRef & { postContent: boolean }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(MessagingConnectionsSchema)
|
||||
.set({ postContent: args.postContent, updatedAt: new Date() })
|
||||
.where(this.ownedWhere(args))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchCollabEmail(collabId: number): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.where(eq(CollaborationUsersSchema.id, collabId))
|
||||
.limit(1)
|
||||
);
|
||||
return rows?.[0]?.email ?? null;
|
||||
}
|
||||
|
||||
async fetchProjectMemberRecipients(goalId: number): Promise<MessagingRecipient[]> {
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
async fetchGoalName(goalId: number): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ name: GoalsSchema.name }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1)
|
||||
);
|
||||
return rows?.[0]?.name ?? null;
|
||||
}
|
||||
|
||||
async fetchGoalOrgSlug(goalId: number): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
|
||||
.from(GoalsSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(GoalsSchema.id, goalId))
|
||||
.limit(1)
|
||||
);
|
||||
return rows?.[0]?.slug ?? null;
|
||||
}
|
||||
|
||||
async filterCollabIdsInGoal(collabIds: number[], goalId: number): Promise<number[]> {
|
||||
if (collabIds.length === 0) return [];
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: CollaborationUsersToGoalsSchema.userId })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.where(and(
|
||||
eq(CollaborationUsersToGoalsSchema.goalId, goalId),
|
||||
inArray(CollaborationUsersToGoalsSchema.userId, collabIds),
|
||||
))
|
||||
);
|
||||
return (rows ?? []).map(r => r.userId);
|
||||
}
|
||||
|
||||
async fetchCurrentAssigneeCollabIds(taskId: number): Promise<number[]> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ collabUserId: TasksAssigneeSchema.collabUserId })
|
||||
.from(TasksAssigneeSchema)
|
||||
.where(eq(TasksAssigneeSchema.taskId, taskId))
|
||||
);
|
||||
return (rows ?? []).map(r => r.collabUserId);
|
||||
}
|
||||
|
||||
async createLinkToken(data: MessagingLinkTokenCreate): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(MessagingLinkTokensSchema)
|
||||
.values({ ...data, token: this.hashToken(data.token) })
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async findValidLinkToken(provider: string, token: string): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingLinkTokensSchema).where(
|
||||
and(
|
||||
eq(MessagingLinkTokensSchema.provider, provider),
|
||||
eq(MessagingLinkTokensSchema.token, this.hashToken(token)),
|
||||
gt(MessagingLinkTokensSchema.expiresAt, new Date()),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async consumeLinkToken(id: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(MessagingLinkTokensSchema).where(eq(MessagingLinkTokensSchema.id, id))
|
||||
);
|
||||
}
|
||||
|
||||
async findUserIdByExternalId(args: MessagingIdentityLookup): Promise<number | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: MessagingIdentityMapSchema.userId })
|
||||
.from(MessagingIdentityMapSchema)
|
||||
.where(and(
|
||||
eq(MessagingIdentityMapSchema.provider, args.provider),
|
||||
eq(MessagingIdentityMapSchema.externalUserId, args.externalUserId),
|
||||
args.externalTeamId === null
|
||||
? isNull(MessagingIdentityMapSchema.externalTeamId)
|
||||
: eq(MessagingIdentityMapSchema.externalTeamId, args.externalTeamId),
|
||||
))
|
||||
.limit(1)
|
||||
);
|
||||
return rows?.[0]?.userId ?? null;
|
||||
}
|
||||
|
||||
async fetchProjectGoalIdsByChannel(args: MessagingChannelLookup): Promise<number[]> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ goalId: MessagingConnectionsSchema.ownerId })
|
||||
.from(MessagingConnectionsSchema)
|
||||
.where(and(
|
||||
eq(MessagingConnectionsSchema.provider, args.provider),
|
||||
eq(MessagingConnectionsSchema.ownerType, 'project'),
|
||||
eq(MessagingConnectionsSchema.targetChatId, args.channelId),
|
||||
eq(MessagingConnectionsSchema.isActive, true),
|
||||
args.externalTeamId === null
|
||||
? isNull(MessagingConnectionsSchema.externalTeamId)
|
||||
: eq(MessagingConnectionsSchema.externalTeamId, args.externalTeamId),
|
||||
))
|
||||
);
|
||||
return rows?.map((r) => r.goalId) ?? [];
|
||||
}
|
||||
|
||||
async fetchSlackBotTokenEncrypted(teamId: string): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ token: MessagingConnectionsSchema.accessTokenEncrypted })
|
||||
.from(MessagingConnectionsSchema)
|
||||
.where(and(
|
||||
eq(MessagingConnectionsSchema.provider, 'slack'),
|
||||
eq(MessagingConnectionsSchema.externalTeamId, teamId),
|
||||
eq(MessagingConnectionsSchema.isActive, true),
|
||||
))
|
||||
);
|
||||
return rows?.map((r) => r.token).find((t): t is string => !!t && !t.startsWith(SLACK_WEBHOOK_PREFIX)) ?? null;
|
||||
}
|
||||
|
||||
async fetchGoalCollabMembers(goalId: number): Promise<{ collabId: number; email: string }[]> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.selectDistinct({ collabId: CollaborationUsersSchema.id, email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
async upsertIdentity(args: MessagingIdentityUpsert): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(MessagingIdentityMapSchema)
|
||||
.values({ userId: args.userId, provider: args.provider, externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: [MessagingIdentityMapSchema.userId, MessagingIdentityMapSchema.provider],
|
||||
set: { externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
|
||||
import { MessagingController } from './MessagingController'
|
||||
import { VerifyTelegramWebhook } from './middlewares/VerifyTelegramWebhook'
|
||||
import { VerifySlackRequest } from './middlewares/VerifySlackRequest'
|
||||
import { CanManageProjectMessaging, CanViewProjectMessaging } from './middlewares/ProjectMessagingPermission'
|
||||
|
||||
export default class MessagingRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: MessagingController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new MessagingController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
// Personal (per-user) connections — scoped to the authenticated user.
|
||||
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch)
|
||||
this.router.patch('/toggle', [IsLoggedIn, RejectApiTokenAuth], this.controller.toggle)
|
||||
this.router.patch('/events', [IsLoggedIn, RejectApiTokenAuth], this.controller.updateEvents)
|
||||
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete)
|
||||
this.router.get('/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth], this.controller.connectLink)
|
||||
|
||||
// Project connections — guarded by project ownership (goalId in body/query).
|
||||
this.router.get('/project', [IsLoggedIn, RejectApiTokenAuth, CanViewProjectMessaging], this.controller.fetchProject)
|
||||
this.router.patch('/project/toggle', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.toggleProject)
|
||||
this.router.patch('/project/events', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectEvents)
|
||||
this.router.patch('/project/post-content', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectPostContent)
|
||||
this.router.delete('/project', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.deleteProject)
|
||||
this.router.get('/project/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.projectConnectLink)
|
||||
|
||||
// Slack OAuth — public (auth carried by the JWT ?token on start and the signed state on callback).
|
||||
this.router.get('/slack/oauth/start', this.controller.slackOAuthStart)
|
||||
this.router.get('/slack/oauth/callback', this.controller.slackOAuthCallback)
|
||||
|
||||
// Inbound webhook — public, protected by the Telegram secret-token header.
|
||||
this.router.post('/telegram/webhook', [VerifyTelegramWebhook], this.controller.telegramWebhook)
|
||||
|
||||
// Slack inbound (slash commands + interactivity) — public, protected by the Slack signature.
|
||||
this.router.post('/slack/commands', [VerifySlackRequest], this.controller.slackCommands)
|
||||
this.router.post('/slack/interactivity', [VerifySlackRequest], this.controller.slackInteractivity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { MessagingRepository } from './MessagingRepository';
|
||||
import { SlackProvider } from './providers/slack.provider';
|
||||
import {
|
||||
SLACK_ACTION_ASSIGN,
|
||||
SLACK_ACTION_DONE,
|
||||
SLACK_ACTION_REOPEN,
|
||||
SLACK_ASSIGN_BLOCK,
|
||||
SLACK_ASSIGN_SELECT_ACTION,
|
||||
SLACK_VIEW_ASSIGN_CALLBACK,
|
||||
} from './providers/slack.constants';
|
||||
import { buildTaskDeepLink, escapeSlackText } from './utils';
|
||||
import type { SlackEphemeralReply, SlackInteractionPayload, SlackSlashCommandPayload } from './types.internal';
|
||||
|
||||
// Orchestrates inbound Slack actions: /task (create), and Done / Assign buttons.
|
||||
// Every path resolves the Slack user to a TaskView user (identity map) and enforces
|
||||
// that user's RBAC before touching anything.
|
||||
export class SlackInboundManager {
|
||||
private readonly repository = new MessagingRepository();
|
||||
private readonly slack = new SlackProvider();
|
||||
|
||||
async handleSlashCommand(payload: SlackSlashCommandPayload): Promise<SlackEphemeralReply> {
|
||||
const userId = await this.resolveUser(payload.user_id, payload.team_id);
|
||||
if (!userId) return this.ephemeral(this.linkPrompt());
|
||||
|
||||
const description = (payload.text ?? '').trim();
|
||||
if (!description) return this.ephemeral('Usage: /task <description>');
|
||||
|
||||
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: 'slack', channelId: payload.channel_id ?? '', externalTeamId: payload.team_id ?? null });
|
||||
if (goalIds.length === 0) return this.ephemeral('This channel is not linked to a TaskView project.');
|
||||
if (goalIds.length > 1) return this.ephemeral('This channel is linked to several projects — create the task in TaskView.');
|
||||
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return this.ephemeral('Could not resolve your TaskView account.');
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
|
||||
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
|
||||
return this.ephemeral('You do not have permission to create tasks in this project.');
|
||||
}
|
||||
|
||||
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description });
|
||||
if (!created?.[0]) return this.ephemeral('Failed to create the task.');
|
||||
return this.ephemeral(`✅ Task created: ${description}`);
|
||||
}
|
||||
|
||||
async handleInteraction(interaction: SlackInteractionPayload): Promise<object | void> {
|
||||
if (interaction.type === 'view_submission') return this.handleAssignSubmit(interaction);
|
||||
if (interaction.type === 'block_actions') return this.handleBlockAction(interaction);
|
||||
}
|
||||
|
||||
private async handleBlockAction(i: SlackInteractionPayload): Promise<void> {
|
||||
const action = i.actions?.[0];
|
||||
const taskId = Number(action?.value);
|
||||
if (!action?.action_id || !taskId) return;
|
||||
|
||||
const userId = await this.resolveUser(i.user?.id, i.team?.id);
|
||||
if (!userId) return this.ackEphemeral(i.response_url, this.linkPrompt());
|
||||
|
||||
if (action.action_id === SLACK_ACTION_DONE) return this.completeTask(i, userId, taskId);
|
||||
if (action.action_id === SLACK_ACTION_REOPEN) return this.reopenTask(i, userId, taskId);
|
||||
if (action.action_id === SLACK_ACTION_ASSIGN) return this.openAssignModal(i, userId, taskId);
|
||||
}
|
||||
|
||||
private async completeTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return;
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
|
||||
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
|
||||
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
}
|
||||
|
||||
await appUser.tasksManager.updateTask({ id: taskId, complete: true });
|
||||
// Show which task (description gated by content permission, like notifications) + a link.
|
||||
const label = checker.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) && task.description
|
||||
? task.description.slice(0, 200)
|
||||
: `#${taskId}`;
|
||||
const orgSlug = await this.repository.fetchGoalOrgSlug(task.goalId);
|
||||
const url = buildTaskDeepLink(orgSlug, task.goalId, taskId, task.goalListId ?? null);
|
||||
const text = url ? `✅ Task completed: <${url}|${escapeSlackText(label)}>` : `✅ Task completed: ${escapeSlackText(label)}`;
|
||||
await this.ackEphemeral(i.response_url, text);
|
||||
}
|
||||
|
||||
private async reopenTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return;
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
|
||||
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
|
||||
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
}
|
||||
|
||||
await appUser.tasksManager.updateTask({ id: taskId, complete: false });
|
||||
await this.ackEphemeral(i.response_url, '↩️ Task reopened');
|
||||
}
|
||||
|
||||
private async openAssignModal(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return;
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
|
||||
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
|
||||
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
}
|
||||
|
||||
const members = await this.repository.fetchGoalCollabMembers(task.goalId);
|
||||
if (members.length === 0) return this.ackEphemeral(i.response_url, 'This project has no members to assign.');
|
||||
|
||||
const botToken = await this.botTokenForTeam(i.team?.id);
|
||||
if (!botToken || !i.trigger_id) return this.ackEphemeral(i.response_url, 'Slack workspace is not fully connected.');
|
||||
|
||||
// Pre-select the current assignees so the modal shows who's assigned and lets the
|
||||
// user add or remove — submit sets the whole list.
|
||||
const currentAssignees = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
|
||||
const metadata = JSON.stringify({ taskId });
|
||||
await this.slack.openModal({ botToken, triggerId: i.trigger_id, view: this.assignView(members, currentAssignees, metadata) });
|
||||
}
|
||||
|
||||
private async handleAssignSubmit(i: SlackInteractionPayload): Promise<object> {
|
||||
if (i.view?.callback_id !== SLACK_VIEW_ASSIGN_CALLBACK) return {};
|
||||
const meta = this.parseMeta(i.view.private_metadata);
|
||||
const taskId = Number(meta.taskId);
|
||||
if (!taskId) return {};
|
||||
|
||||
const selected = i.view.state?.values?.[SLACK_ASSIGN_BLOCK]?.[SLACK_ASSIGN_SELECT_ACTION]?.selected_options ?? [];
|
||||
const selectedCollabIds = selected.map((o) => Number(o.value)).filter((n) => Number.isInteger(n) && n > 0);
|
||||
|
||||
const userId = await this.resolveUser(i.user?.id, i.team?.id);
|
||||
if (!userId) return this.viewError(this.linkPrompt());
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return {};
|
||||
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
if (!task) return {};
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
|
||||
return this.viewError('You do not have permission to assign this task.');
|
||||
}
|
||||
|
||||
// toggleTaskUsers is called directly (not via the HTTP CanUpdateTaskAssignee guard),
|
||||
// so re-validate every selected id is a member of this goal — Slack doesn't guarantee
|
||||
// the submitted values are among the options we offered.
|
||||
const valid = await this.repository.filterCollabIdsInGoal(selectedCollabIds, task.goalId);
|
||||
if (valid.length !== selectedCollabIds.length) return this.viewError('One of the selected users is not a member of this project.');
|
||||
|
||||
// SET the assignee list to exactly the selection — this both adds and removes.
|
||||
// An empty selection clears everyone (the input block is optional).
|
||||
await appUser.tasksManager.toggleTaskUsers({ taskId, userIds: selectedCollabIds });
|
||||
return {}; // closes the modal
|
||||
}
|
||||
|
||||
private async resolveUser(slackUserId?: string, teamId?: string): Promise<number | null> {
|
||||
if (!slackUserId) return null;
|
||||
// Scope by workspace: a Slack user id is unique only within its team.
|
||||
return this.repository.findUserIdByExternalId({ provider: 'slack', externalUserId: slackUserId, externalTeamId: teamId ?? null });
|
||||
}
|
||||
|
||||
private async buildAppUser(userId: number): Promise<AppUser | null> {
|
||||
const record = await new AppUser().authManager.repository.fetchUserById(userId);
|
||||
if (!record || record.block !== 0) return null;
|
||||
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
|
||||
}
|
||||
|
||||
private async botTokenForTeam(teamId?: string): Promise<string | null> {
|
||||
if (!teamId) return null;
|
||||
const encrypted = await this.repository.fetchSlackBotTokenEncrypted(teamId);
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private assignView(members: { collabId: number; email: string }[], currentAssignees: number[], privateMetadata: string): object {
|
||||
const current = new Set(currentAssignees);
|
||||
const options = members.slice(0, 100).map((m) => ({
|
||||
text: { type: 'plain_text', text: m.email.slice(0, 75) },
|
||||
value: String(m.collabId),
|
||||
}));
|
||||
const initialOptions = options.filter((o) => current.has(Number(o.value)));
|
||||
|
||||
const element: Record<string, unknown> = {
|
||||
type: 'multi_static_select',
|
||||
action_id: SLACK_ASSIGN_SELECT_ACTION,
|
||||
placeholder: { type: 'plain_text', text: 'Select assignees' },
|
||||
options,
|
||||
};
|
||||
// Slack rejects an empty initial_options array — only set it when there are current ones.
|
||||
if (initialOptions.length > 0) element.initial_options = initialOptions;
|
||||
|
||||
return {
|
||||
type: 'modal',
|
||||
callback_id: SLACK_VIEW_ASSIGN_CALLBACK,
|
||||
private_metadata: privateMetadata,
|
||||
title: { type: 'plain_text', text: 'Assignees' },
|
||||
submit: { type: 'plain_text', text: 'Save' },
|
||||
close: { type: 'plain_text', text: 'Cancel' },
|
||||
blocks: [
|
||||
{
|
||||
type: 'input',
|
||||
block_id: SLACK_ASSIGN_BLOCK,
|
||||
optional: true, // allow clearing everyone
|
||||
label: { type: 'plain_text', text: 'Assignees' },
|
||||
element,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private parseMeta(raw?: string): { taskId?: number } {
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private viewError(message: string): object {
|
||||
return { response_action: 'errors', errors: { [SLACK_ASSIGN_BLOCK]: message } };
|
||||
}
|
||||
|
||||
private async ackEphemeral(responseUrl: string | undefined, text: string): Promise<void> {
|
||||
if (responseUrl) await this.slack.respondEphemeral(responseUrl, text);
|
||||
}
|
||||
|
||||
private ephemeral(text: string): SlackEphemeralReply {
|
||||
return { response_type: 'ephemeral', text };
|
||||
}
|
||||
|
||||
private linkPrompt(): string {
|
||||
return `Link your Slack account in TaskView first: ${process.env.APP_URL ?? ''}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Configuration constants for the messaging module. Endpoints are env-overridable
|
||||
// (a mock server in tests, or an enterprise proxy), defaulting to the public provider
|
||||
// URLs. The Slack Web API methods all derive from one base.
|
||||
|
||||
const SLACK_API_BASE = process.env.SLACK_API_BASE_URL || 'https://slack.com/api';
|
||||
|
||||
export const SLACK_AUTHORIZE_URL = process.env.SLACK_AUTHORIZE_URL || 'https://slack.com/oauth/v2/authorize';
|
||||
export const SLACK_WEBHOOK_PREFIX = process.env.SLACK_WEBHOOK_PREFIX || 'https://hooks.slack.com/';
|
||||
export const SLACK_TOKEN_URL = `${SLACK_API_BASE}/oauth.v2.access`;
|
||||
export const SLACK_POST_MESSAGE_URL = `${SLACK_API_BASE}/chat.postMessage`;
|
||||
export const SLACK_UPDATE_MESSAGE_URL = `${SLACK_API_BASE}/chat.update`;
|
||||
export const SLACK_VIEWS_OPEN_URL = `${SLACK_API_BASE}/views.open`;
|
||||
|
||||
export const SLACK_OAUTH_NONCE_COOKIE = 'msg_slack_oauth_nonce';
|
||||
export const OAUTH_STATE_TTL = '10m';
|
||||
export const OAUTH_NONCE_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export const TELEGRAM_API = 'https://api.telegram.org';
|
||||
|
||||
// Lifetime of a deep-link binding token (connect-link flow).
|
||||
export const LINK_TTL_MS = 15 * 60 * 1000;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BuildMessagingMessageArgs, MessagingEvent, MessagingMessage } from './types';
|
||||
|
||||
type Audience = 'personal' | 'project';
|
||||
|
||||
// Delivered message titles. English on the server, matching NotificationMessages —
|
||||
// the backend has no i18n/per-recipient locale, so notification text is English by
|
||||
// convention. (The event-selection UI is localized separately via frontend i18n.)
|
||||
// Personal wording is second-person where it differs from the project-channel wording.
|
||||
const TITLES: Record<MessagingEvent, { personal: string; project: string }> = {
|
||||
'task.created': { personal: '[New task]', project: '[New task]' },
|
||||
'task.assigned': { personal: '[Task assigned to you]', project: '[Task assigned]' },
|
||||
'task.statusChanged': { personal: '[Task status changed]', project: '[Task status changed]' },
|
||||
'task.completed': { personal: '[Task completed]', project: '[Task completed]' },
|
||||
'task.edited': { personal: '[Task edited]', project: '[Task edited]' },
|
||||
'task.addedToSprint': { personal: '[Task added to sprint]', project: '[Task added to sprint]' },
|
||||
'task.deleted': { personal: '[Task deleted]', project: '[Task deleted]' },
|
||||
'sprint.created': { personal: '[Sprint created]', project: '[Sprint created]' },
|
||||
'sprint.updated': { personal: '[Sprint edited]', project: '[Sprint edited]' },
|
||||
'sprint.started': { personal: '[Sprint started]', project: '[Sprint started]' },
|
||||
'sprint.reviewStarted': { personal: '[Sprint review started]', project: '[Sprint review started]' },
|
||||
'sprint.completed': { personal: '[Sprint completed]', project: '[Sprint completed]' },
|
||||
'sprint.paused': { personal: '[Sprint paused]', project: '[Sprint paused]' },
|
||||
'sprint.resumed': { personal: '[Sprint resumed]', project: '[Sprint resumed]' },
|
||||
'sprint.deleted': { personal: '[Sprint deleted]', project: '[Sprint deleted]' },
|
||||
'member.added': { personal: '[Member added]', project: '[Member added]' },
|
||||
'member.removed': { personal: '[Member removed]', project: '[Member removed]' },
|
||||
'member.rolesChanged': { personal: '[Member roles changed]', project: '[Member roles changed]' },
|
||||
'time.started': { personal: '[Timer started]', project: '[Timer started]' },
|
||||
'time.stopped': { personal: '[Timer stopped]', project: '[Timer stopped]' },
|
||||
'time.logged': { personal: '[Time logged]', project: '[Time logged]' },
|
||||
'time.updated': { personal: '[Time entry edited]', project: '[Time entry edited]' },
|
||||
'time.deleted': { personal: '[Time entry deleted]', project: '[Time entry deleted]' },
|
||||
'recurrence.created': { personal: '[Recurrence created]', project: '[Recurrence created]' },
|
||||
'recurrence.updated': { personal: '[Recurrence edited]', project: '[Recurrence edited]' },
|
||||
'recurrence.paused': { personal: '[Recurrence paused]', project: '[Recurrence paused]' },
|
||||
'recurrence.resumed': { personal: '[Recurrence resumed]', project: '[Recurrence resumed]' },
|
||||
'recurrence.ended': { personal: '[Recurrence ended]', project: '[Recurrence ended]' },
|
||||
'recurrence.deleted': { personal: '[Recurrence deleted]', project: '[Recurrence deleted]' },
|
||||
'recurrence.skipped': { personal: '[Occurrence skipped]', project: '[Occurrence skipped]' },
|
||||
};
|
||||
|
||||
export function buildMessagingMessage(args: BuildMessagingMessageArgs): MessagingMessage {
|
||||
const base = args.titleOverride ?? TITLES[args.event][args.audience];
|
||||
const title = args.projectName ? `${base} [${args.projectName}]` : base;
|
||||
return { event: args.event, title, body: args.body, url: args.url, taskId: args.taskId, footer: args.footer, completed: args.completed, actions: args.actions };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions, type GoalPermissionType } from '../../../types/auth.types';
|
||||
|
||||
function requireProjectPermission(permission: GoalPermissionType) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = Number(req.body?.goalId ?? req.query?.goalId);
|
||||
if (!goalId || Number.isNaN(goalId)) return res.status(400).end();
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (!checker.hasPermissions(permission)) return res.status(403).end();
|
||||
|
||||
res.locals.messagingGoalId = goalId;
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export const CanManageProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_MANAGE);
|
||||
export const CanViewProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_VIEW);
|
||||
|
||||
export function getAuthorizedGoalId(res: Response): number {
|
||||
return res.locals.messagingGoalId as number;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
const MAX_AGE_SECONDS = 300;
|
||||
|
||||
// Verifies inbound Slack requests (slash commands, interactivity) via the app Signing
|
||||
// Secret: HMAC-SHA256 over `v0:${timestamp}:${rawBody}`, compared timing-safe against the
|
||||
// X-Slack-Signature header, with a 5-minute timestamp window to blunt replay.
|
||||
export const VerifySlackRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||
const secret = process.env.SLACK_SIGNING_SECRET;
|
||||
if (!secret) {
|
||||
$logger.error('[Messaging/Slack] SLACK_SIGNING_SECRET is not set — rejecting inbound request');
|
||||
return res.status(503).end();
|
||||
}
|
||||
|
||||
const signature = req.headers['x-slack-signature'] as string | undefined;
|
||||
const timestamp = req.headers['x-slack-request-timestamp'] as string | undefined;
|
||||
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody;
|
||||
if (!signature || !timestamp || !rawBody) return res.status(401).end();
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) return res.status(401).end();
|
||||
|
||||
const expected = `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${rawBody.toString('utf8')}`).digest('hex')}`;
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
const signatureBuf = Buffer.from(signature);
|
||||
if (expectedBuf.length !== signatureBuf.length || !timingSafeEqual(expectedBuf, signatureBuf)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
/**
|
||||
* Telegram sends the value configured via setWebhook's secret_token in this
|
||||
* header on every update. Reject anything that doesn't match so the public
|
||||
* inbound endpoint can't be spoofed.
|
||||
*/
|
||||
export function VerifyTelegramWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
const expected = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
$logger.warn('[Messaging/Telegram] webhook rejected: TELEGRAM_WEBHOOK_SECRET not set');
|
||||
return res.status(503).end();
|
||||
}
|
||||
|
||||
const provided = req.header('X-Telegram-Bot-Api-Secret-Token') ?? '';
|
||||
if (!safeEqual(provided, expected)) {
|
||||
$logger.warn({ hasHeader: !!req.header('X-Telegram-Bot-Api-Secret-Token') }, '[Messaging/Telegram] webhook rejected: secret mismatch');
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a);
|
||||
const bBuf = Buffer.from(b);
|
||||
if (aBuf.length !== bBuf.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(aBuf, bBuf);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingProviderId } from '../types';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw } from '../types.internal';
|
||||
|
||||
/**
|
||||
* Full contract for a messaging provider (Telegram, Slack, …). A provider owns its
|
||||
* protocol end-to-end — building connect URLs, exchanging OAuth codes, parsing inbound
|
||||
* payloads, calling the messenger API. It returns normalized data; the manager does the
|
||||
* business work (persistence, RBAC) and never branches on the concrete provider.
|
||||
*/
|
||||
export interface MessagingProvider {
|
||||
readonly id: MessagingProviderId;
|
||||
|
||||
/** Whether this instance has the credentials needed to operate (env/admin config). */
|
||||
isConfigured(): boolean;
|
||||
|
||||
/** Send one message to a chat/channel. Never throws — failures come back as a result. */
|
||||
deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult>;
|
||||
|
||||
/**
|
||||
* Everything needed to start connecting this owner: an authorize/deep-link URL, plus
|
||||
* optionally a token to persist (deep-link flows) or a cookie to set (OAuth anti-CSRF).
|
||||
*/
|
||||
startConnect(ctx: ConnectContext): Promise<ConnectStart>;
|
||||
|
||||
/** Parse a raw inbound payload (webhook / OAuth callback) into a normalized intent, or null. */
|
||||
parseInbound(raw: InboundRaw): Promise<InboundIntent | null>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Slack Block Kit action / callback identifiers, shared between the provider (which
|
||||
// renders the buttons/modal) and the inbound handler (which routes interactions by them).
|
||||
export const SLACK_ACTION_DONE = 'tv_task_done';
|
||||
export const SLACK_ACTION_REOPEN = 'tv_task_reopen';
|
||||
export const SLACK_ACTION_ASSIGN = 'tv_task_assign';
|
||||
export const SLACK_VIEW_ASSIGN_CALLBACK = 'tv_assign_submit';
|
||||
export const SLACK_ASSIGN_BLOCK = 'tv_assign_block';
|
||||
export const SLACK_ASSIGN_SELECT_ACTION = 'tv_assign_select';
|
||||
@@ -0,0 +1,290 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { encrypt } from '../../../utils/crypto';
|
||||
import type { MessagingProvider } from './MessagingProvider';
|
||||
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
|
||||
import type { MessagingOwnerType } from '../types';
|
||||
import { SLACK_ACTION_ASSIGN, SLACK_ACTION_DONE, SLACK_ACTION_REOPEN } from './slack.constants';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingOAuthState, SlackOAuthAccessResponse, SlackOAuthExchange, SlackOpenModalArgs } from '../types.internal';
|
||||
import {
|
||||
OAUTH_NONCE_MAX_AGE_MS,
|
||||
OAUTH_STATE_TTL,
|
||||
SLACK_AUTHORIZE_URL,
|
||||
SLACK_OAUTH_NONCE_COOKIE,
|
||||
SLACK_POST_MESSAGE_URL,
|
||||
SLACK_TOKEN_URL,
|
||||
SLACK_VIEWS_OPEN_URL,
|
||||
SLACK_WEBHOOK_PREFIX,
|
||||
} from '../config';
|
||||
import { escapeSlackText, isSafeUrl } from '../utils';
|
||||
|
||||
/**
|
||||
* Slack provider. Bot credentials are instance-level (env), like GitHub/GitLab.
|
||||
* Personal connections DM the installing user (bot token + chat.postMessage);
|
||||
* project connections use an incoming webhook (channel chosen during install).
|
||||
*/
|
||||
export class SlackProvider implements MessagingProvider {
|
||||
readonly id: MessagingProviderId = 'slack';
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!process.env.SLACK_CLIENT_ID && !!process.env.SLACK_CLIENT_SECRET && !!process.env.SLACK_CALLBACK_URL;
|
||||
}
|
||||
|
||||
getOAuthUrl(state: string, ownerType: MessagingOwnerType): string {
|
||||
const clientId = process.env.SLACK_CLIENT_ID;
|
||||
const redirectUri = process.env.SLACK_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('Slack integration OAuth is not configured');
|
||||
}
|
||||
// Personal → bot posts a DM to the installer. Project → channel is picked at
|
||||
// install via incoming-webhook, and chat:write lets us post + update messages
|
||||
// (chat.update) and open modals for the interactive buttons via the bot token.
|
||||
const scope = ownerType === 'project' ? 'incoming-webhook,chat:write' : 'chat:write';
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
scope,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
});
|
||||
return `${SLACK_AUTHORIZE_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<SlackOAuthExchange> {
|
||||
const redirectUri = process.env.SLACK_CALLBACK_URL;
|
||||
const res = await axios.post<SlackOAuthAccessResponse>(
|
||||
SLACK_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
client_id: process.env.SLACK_CLIENT_ID ?? '',
|
||||
client_secret: process.env.SLACK_CLIENT_SECRET ?? '',
|
||||
code,
|
||||
redirect_uri: redirectUri ?? '',
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
);
|
||||
|
||||
const data = res.data;
|
||||
if (!data.ok || !data.access_token) {
|
||||
throw new Error(`Slack code exchange failed: ${data.error ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
return {
|
||||
botToken: data.access_token,
|
||||
teamId: data.team?.id ?? null,
|
||||
teamName: data.team?.name ?? null,
|
||||
authedUserId: data.authed_user?.id ?? null,
|
||||
webhookUrl: data.incoming_webhook?.url ?? null,
|
||||
webhookChannel: data.incoming_webhook?.channel ?? null,
|
||||
webhookChannelId: data.incoming_webhook?.channel_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
|
||||
// Anti-CSRF: a nonce is set as a cookie on the initiating browser and its hash is
|
||||
// carried inside the signed state, verified on callback.
|
||||
const nonce = randomBytes(32).toString('hex');
|
||||
const state = jwt.sign(
|
||||
{
|
||||
provider: 'slack',
|
||||
ownerType: ctx.ownerType,
|
||||
ownerId: ctx.ownerId,
|
||||
userId: ctx.userId,
|
||||
nonceHash: this.hashNonce(nonce),
|
||||
returnPath: ctx.returnPath ?? '',
|
||||
} as MessagingOAuthState,
|
||||
process.env.JWT_SIGN as string,
|
||||
{ expiresIn: OAUTH_STATE_TTL },
|
||||
);
|
||||
return {
|
||||
kind: 'oauth',
|
||||
url: this.getOAuthUrl(state, ctx.ownerType),
|
||||
setCookie: { name: SLACK_OAUTH_NONCE_COOKIE, value: nonce, maxAgeMs: OAUTH_NONCE_MAX_AGE_MS },
|
||||
};
|
||||
}
|
||||
|
||||
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
|
||||
// Slash commands / interactivity are handled by SlackInboundManager (they need
|
||||
// multi-step Slack UI). Here we only complete the OAuth connect round-trip.
|
||||
if (raw.source !== 'oauth-callback') return null;
|
||||
const { code, state } = (raw.payload as { code?: string; state?: string }) ?? {};
|
||||
if (!code || !state) return null;
|
||||
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as MessagingOAuthState;
|
||||
if (payload.provider !== 'slack') throw new Error('Provider mismatch in OAuth state');
|
||||
|
||||
// The state's nonce hash must match the cookie set on the initiating browser.
|
||||
// Enforced only in production: local dev often splits the frontend (localhost) from
|
||||
// the public callback (tunnel), where a single browser cookie can't bridge origins.
|
||||
const nonceOk = !!raw.cookie && this.hashNonce(raw.cookie) === payload.nonceHash;
|
||||
if (!nonceOk) {
|
||||
if (process.env.NODE_ENV === 'production') throw new Error('OAuth state/nonce mismatch');
|
||||
$logger.warn('[Messaging/Slack] Skipping OAuth nonce check (non-production; split-origin dev)');
|
||||
}
|
||||
|
||||
const ex = await this.exchangeCode(code);
|
||||
|
||||
if (payload.ownerType === 'project') {
|
||||
if (!ex.webhookChannelId) throw new Error('Slack did not return the chosen channel');
|
||||
// Store the bot token (not the webhook URL): posting via chat.postMessage also
|
||||
// lets us update messages and open modals for the interactive buttons.
|
||||
return {
|
||||
kind: 'createConnection',
|
||||
redirect: payload.returnPath,
|
||||
connection: {
|
||||
provider: 'slack',
|
||||
ownerType: 'project',
|
||||
ownerId: payload.ownerId,
|
||||
targetChatId: ex.webhookChannelId,
|
||||
title: ex.webhookChannel,
|
||||
externalTeamId: ex.teamId,
|
||||
accessTokenEncrypted: encrypt(ex.botToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!ex.authedUserId) throw new Error('Slack did not return the installing user');
|
||||
return {
|
||||
kind: 'createConnection',
|
||||
redirect: payload.returnPath,
|
||||
identity: { userId: payload.userId, externalUserId: ex.authedUserId, externalTeamId: ex.teamId ?? null },
|
||||
connection: {
|
||||
provider: 'slack',
|
||||
ownerType: 'user',
|
||||
ownerId: payload.userId,
|
||||
targetChatId: ex.authedUserId,
|
||||
title: ex.teamName ? `Slack (${ex.teamName})` : 'Slack',
|
||||
externalTeamId: ex.teamId,
|
||||
accessTokenEncrypted: encrypt(ex.botToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private hashNonce(nonce: string): string {
|
||||
return createHash('sha256').update(nonce).digest('hex');
|
||||
}
|
||||
|
||||
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
|
||||
if (!args.accessToken) return { success: false };
|
||||
const secret = args.accessToken;
|
||||
const text = this.render(args.message);
|
||||
const blocks = this.buildTaskBlocks(args.message);
|
||||
|
||||
try {
|
||||
// Legacy project connections store a webhook URL (no bot token → no interactive
|
||||
// buttons). Newer connections store a bot token and post via chat.postMessage.
|
||||
if (secret.startsWith(SLACK_WEBHOOK_PREFIX)) {
|
||||
const res = await axios.post(secret, { text, blocks }, { timeout: 10000 });
|
||||
return { success: res.status === 200 };
|
||||
}
|
||||
|
||||
const res = await axios.post<{ ok: boolean; error?: string }>(
|
||||
SLACK_POST_MESSAGE_URL,
|
||||
{ channel: args.chatId, text, blocks },
|
||||
{ headers: { Authorization: `Bearer ${secret}` }, timeout: 10000 },
|
||||
);
|
||||
if (!res.data.ok) {
|
||||
$logger.warn(`[Messaging/Slack] chat.postMessage failed: ${res.data.error ?? 'unknown'} (channel=${args.chatId})`);
|
||||
}
|
||||
return { success: res.data.ok, errorCode: res.data.ok ? undefined : 400 };
|
||||
} catch (err) {
|
||||
// Never log the raw axios error — its config.url (incoming webhook, itself a
|
||||
// secret) / config.headers.Authorization (bot token) would land in disk logs.
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
$logger.error({ status }, `[Messaging/Slack] Delivery failed to ${args.chatId}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a modal (assignee picker). trigger_id from the interaction expires in ~3s. */
|
||||
async openModal(args: SlackOpenModalArgs): Promise<boolean> {
|
||||
return this.callApi(SLACK_VIEWS_OPEN_URL, args.botToken, { trigger_id: args.triggerId, view: args.view });
|
||||
}
|
||||
|
||||
/** Ephemeral feedback for an interaction — posts to the payload's response_url (no token). */
|
||||
async respondEphemeral(responseUrl: string, text: string): Promise<void> {
|
||||
// response_url is Slack-supplied; pin its host to the configured webhook host so a
|
||||
// trust regression can never turn this into an SSRF to an arbitrary URL.
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(responseUrl).host;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (host !== new URL(SLACK_WEBHOOK_PREFIX).host) return;
|
||||
try {
|
||||
// replace_original:false — post a separate ephemeral note, never overwrite the card
|
||||
// the button was on (the response_url default is to replace the original message).
|
||||
await axios.post(responseUrl, { response_type: 'ephemeral', replace_original: false, text }, { timeout: 10000 });
|
||||
} catch {
|
||||
// Best-effort user feedback; nothing to recover if Slack's response_url is unreachable.
|
||||
}
|
||||
}
|
||||
|
||||
private async callApi(url: string, botToken: string, body: Record<string, unknown>): Promise<boolean> {
|
||||
try {
|
||||
const res = await axios.post<{ ok: boolean; error?: string }>(url, body, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
timeout: 10000,
|
||||
});
|
||||
if (!res.data.ok) $logger.warn(`[Messaging/Slack] ${url.split('/').pop()} failed: ${res.data.error ?? 'unknown'}`);
|
||||
return res.data.ok;
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
$logger.error({ status }, `[Messaging/Slack] API call failed: ${url.split('/').pop()}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Block Kit: a text section plus Done/Assign action buttons for task events. The button
|
||||
// value carries the taskId so the interaction round-trips it back to us.
|
||||
private buildTaskBlocks(message: MessagingMessage): unknown[] {
|
||||
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
|
||||
const blocks: unknown[] = [];
|
||||
|
||||
// Task name as a large header (Slack's biggest text). Header is plain_text only — no
|
||||
// link/markdown — so the clickable label lives in the section below.
|
||||
if (message.body) {
|
||||
blocks.push({ type: 'header', text: { type: 'plain_text', text: message.body.slice(0, 150), emoji: true } });
|
||||
}
|
||||
|
||||
// Event + project label; the whole line links to the task.
|
||||
const label = escapeSlackText(message.title);
|
||||
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: url ? `*<${url}|${label}>*` : `*${label}*` } });
|
||||
// Assignees line above the buttons so the actions sit at the very bottom.
|
||||
if (message.footer) {
|
||||
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: escapeSlackText(message.footer) }] });
|
||||
}
|
||||
if (message.taskId && message.actions !== false) {
|
||||
// A completed task offers Reopen; an open task offers Done.
|
||||
const primary = message.completed
|
||||
? { type: 'button', text: { type: 'plain_text', text: '↩️ Reopen' }, action_id: SLACK_ACTION_REOPEN, value: String(message.taskId) }
|
||||
: { type: 'button', text: { type: 'plain_text', text: '✅ Done' }, action_id: SLACK_ACTION_DONE, value: String(message.taskId) };
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
primary,
|
||||
{ type: 'button', text: { type: 'plain_text', text: '👤 Assign' }, action_id: SLACK_ACTION_ASSIGN, value: String(message.taskId) },
|
||||
],
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private render(message: MessagingMessage): string {
|
||||
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
|
||||
const lines: string[] = [];
|
||||
if (url) {
|
||||
// Slack link syntax <url|text>: hyperlink the description, or the title
|
||||
// when there is no description.
|
||||
lines.push(message.body
|
||||
? `*${escapeSlackText(message.title)}*\n<${url}|${escapeSlackText(message.body)}>`
|
||||
: `*<${url}|${escapeSlackText(message.title)}>*`);
|
||||
} else {
|
||||
lines.push(`*${escapeSlackText(message.title)}*`);
|
||||
if (message.body) lines.push(escapeSlackText(message.body));
|
||||
}
|
||||
if (message.footer) lines.push(escapeSlackText(message.footer));
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import type { MessagingProvider } from './MessagingProvider';
|
||||
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, TelegramInboundMessage } from '../types.internal';
|
||||
import { LINK_TTL_MS, TELEGRAM_API } from '../config';
|
||||
import { escapeHtml, isSafeUrl } from '../utils';
|
||||
|
||||
/**
|
||||
* Telegram bot provider. The bot token is instance-level (env), so the SaaS
|
||||
* ships an official bot and self-hosted installs supply their own via
|
||||
* TELEGRAM_BOT_TOKEN — no code fork, only config.
|
||||
*/
|
||||
export class TelegramProvider implements MessagingProvider {
|
||||
readonly id: MessagingProviderId = 'telegram';
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!process.env.TELEGRAM_BOT_TOKEN && !!process.env.TELEGRAM_BOT_USERNAME;
|
||||
}
|
||||
|
||||
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) return { success: false };
|
||||
|
||||
try {
|
||||
const response = await fetch(`${TELEGRAM_API}/bot${token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: args.chatId,
|
||||
text: this.render(args.message),
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return { success: response.ok, errorCode: response.ok ? undefined : response.status };
|
||||
} catch (err) {
|
||||
// Log only the message — the raw error can carry the request URL, which
|
||||
// embeds the bot token.
|
||||
const message = err instanceof Error ? err.message : 'unknown error';
|
||||
$logger.error({ message }, `[Messaging/Telegram] Delivery failed to chat=${args.chatId}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
|
||||
const username = process.env.TELEGRAM_BOT_USERNAME;
|
||||
const token = randomBytes(24).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
|
||||
// Personal → deep-link into a private chat (auto-sends /start <token>).
|
||||
// Project → startgroup lets the admin add the bot to a group; the bind completes
|
||||
// when they run /connect <token> there.
|
||||
const url = ctx.ownerType === 'user'
|
||||
? `https://t.me/${username}?start=${token}`
|
||||
: `https://t.me/${username}?startgroup=${token}`;
|
||||
return { kind: 'deep-link', url, persistToken: { token, expiresAt } };
|
||||
}
|
||||
|
||||
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
|
||||
if (raw.source !== 'webhook') return null;
|
||||
const message = (raw.payload as { message?: TelegramInboundMessage })?.message;
|
||||
if (!message) return null;
|
||||
|
||||
const text = typeof message.text === 'string' ? message.text.trim() : '';
|
||||
const chatId = message.chat?.id;
|
||||
const chatType = message.chat?.type;
|
||||
const fromId = message.from?.id;
|
||||
if (!text || text.length > 4096 || typeof chatId !== 'number' || typeof fromId !== 'number') return null;
|
||||
|
||||
// /task <description> — create a task in this chat's project.
|
||||
const taskMatch = text.match(/^\/task(?:@\w+)?\s+([\s\S]+)$/);
|
||||
if (taskMatch) {
|
||||
return { kind: 'command', command: 'createTask', text: taskMatch[1].trim().slice(0, 2000), chatId: String(chatId), externalUserId: String(fromId) };
|
||||
}
|
||||
|
||||
const match = text.match(/^\/(?:start|connect)(?:@\w+)?\s+(\S+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
if (chatType === 'private') {
|
||||
const title = typeof message.from?.username === 'string' ? `@${message.from.username}` : null;
|
||||
return { kind: 'bindByToken', token: match[1], scope: 'user', chatId: String(chatId), externalUserId: String(fromId), title };
|
||||
}
|
||||
if (chatType === 'group' || chatType === 'supergroup') {
|
||||
const title = typeof message.chat?.title === 'string' ? message.chat.title : null;
|
||||
return { kind: 'bindByToken', token: match[1], scope: 'project', chatId: String(chatId), externalUserId: String(fromId), title };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private render(message: MessagingMessage): string {
|
||||
// Mirror the Slack layout: task name (bold) on top, the "[event] [project]" label as
|
||||
// the link below, assignees last. Telegram has no header size, so name is just bold.
|
||||
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (message.body) lines.push(`<b>${escapeHtml(message.body)}</b>`);
|
||||
|
||||
const label = escapeHtml(message.title);
|
||||
lines.push(url ? `<a href="${escapeHtml(url)}">${label}</a>` : label);
|
||||
|
||||
if (message.footer) lines.push(escapeHtml(message.footer));
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { MessagingEvent, MessagingOwnerType, MessagingProviderId } from './types';
|
||||
|
||||
export type MessagingConnectionForClient = Omit<MessagingConnectionsSchemaTypeForSelect, 'accessTokenEncrypted'>;
|
||||
|
||||
export interface MessagingRecipient {
|
||||
userId: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SlackOpenModalArgs {
|
||||
botToken: string;
|
||||
triggerId: string;
|
||||
view: unknown;
|
||||
}
|
||||
|
||||
export interface SlackOAuthAccessResponse {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
access_token?: string;
|
||||
team?: { id?: string; name?: string };
|
||||
authed_user?: { id?: string };
|
||||
incoming_webhook?: { url?: string; channel?: string; channel_id?: string };
|
||||
}
|
||||
|
||||
export interface SlackSlashCommandPayload {
|
||||
user_id?: string;
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface SlackInteractionPayload {
|
||||
type?: string;
|
||||
user?: { id?: string };
|
||||
team?: { id?: string };
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
channel?: { id?: string };
|
||||
container?: { message_ts?: string };
|
||||
message?: { ts?: string; blocks?: { type?: string; text?: { text?: string } }[] };
|
||||
actions?: { action_id?: string; value?: string }[];
|
||||
view?: {
|
||||
callback_id?: string;
|
||||
private_metadata?: string;
|
||||
state?: { values?: Record<string, Record<string, { selected_options?: { value?: string }[] }>> };
|
||||
};
|
||||
}
|
||||
|
||||
export interface SlackEphemeralReply {
|
||||
response_type: 'ephemeral';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TelegramInboundMessage {
|
||||
text?: unknown;
|
||||
chat?: { id?: unknown; type?: unknown; title?: unknown };
|
||||
from?: { id?: unknown; username?: unknown };
|
||||
}
|
||||
|
||||
export interface MessagingTaskContext {
|
||||
id: number;
|
||||
goalListId: number | null;
|
||||
description: string | null;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface MessagingDispatchArgs {
|
||||
event: MessagingEvent;
|
||||
goalId: number;
|
||||
personalRecipients: MessagingRecipient[];
|
||||
initiatorId: number | null;
|
||||
/** Simple, non-content body (sprint name, member email, …). */
|
||||
body?: string;
|
||||
/** Task context: description is RBAC-gated per recipient (COMPONENT_CAN_WATCH_CONTENT) + a task deep-link. */
|
||||
task?: MessagingTaskContext;
|
||||
/** Overrides the title (e.g. "[Task reopened]" while still gated by the task.completed subscription). */
|
||||
titleOverride?: string;
|
||||
}
|
||||
|
||||
/** Slack identities are keyed by workspace; Telegram passes externalTeamId = null. */
|
||||
export interface MessagingIdentityUpsert {
|
||||
userId: number;
|
||||
provider: string;
|
||||
externalUserId: string;
|
||||
externalTeamId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingIdentityLookup {
|
||||
provider: string;
|
||||
externalUserId: string;
|
||||
externalTeamId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingChannelLookup {
|
||||
provider: string;
|
||||
channelId: string;
|
||||
externalTeamId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingConnectionCreate {
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
targetChatId: string;
|
||||
title: string | null;
|
||||
externalTeamId: string | null;
|
||||
accessTokenEncrypted: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingOwnedRef {
|
||||
id: number;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
}
|
||||
|
||||
export interface MessagingOwnedToggle extends MessagingOwnedRef {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface MessagingLinkTokenCreate {
|
||||
token: string;
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
createdBy: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface SlackOAuthExchange {
|
||||
botToken: string;
|
||||
teamId: string | null;
|
||||
teamName: string | null;
|
||||
authedUserId: string | null;
|
||||
webhookUrl: string | null;
|
||||
webhookChannel: string | null;
|
||||
webhookChannelId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingOAuthState {
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
userId: number;
|
||||
/** SHA-256 of a nonce also stored in an httpOnly cookie — binds the flow to the initiating browser (anti-CSRF). */
|
||||
nonceHash: string;
|
||||
/** In-app path the user started from, to return them there after the callback. */
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export interface MessagingConnectLinkResult {
|
||||
provider: MessagingProviderId;
|
||||
url: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
// ── Provider lifecycle (single MessagingProvider interface) ─────────────────
|
||||
// Everything a provider needs to start a connection. The manager stays generic:
|
||||
// it never branches on provider — it just persists what the provider returns.
|
||||
|
||||
export interface ConnectContext {
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
userId: number;
|
||||
/** In-app path to return to after an OAuth round-trip (Slack); ignored by others. */
|
||||
returnPath?: string;
|
||||
}
|
||||
|
||||
// Discriminated so the two flows can't be mixed up: a deep-link provider always mints a
|
||||
// token to persist; an OAuth provider always sets an anti-CSRF cookie. Never both.
|
||||
export type ConnectStart =
|
||||
| { kind: 'deep-link'; url: string; persistToken: { token: string; expiresAt: Date } }
|
||||
| { kind: 'oauth'; url: string; setCookie: { name: string; value: string; maxAgeMs: number } };
|
||||
|
||||
/** Raw inbound, tagged by which endpoint received it so the provider can parse accordingly. */
|
||||
export interface InboundRaw {
|
||||
source: 'oauth-callback' | 'webhook';
|
||||
payload: unknown;
|
||||
/** Cookie value echoed back for verification (OAuth nonce). */
|
||||
cookie?: string;
|
||||
}
|
||||
|
||||
/** Normalized inbound outcome. The provider parses protocol; the manager does DB + RBAC. */
|
||||
export type InboundIntent =
|
||||
| {
|
||||
kind: 'createConnection';
|
||||
connection: MessagingConnectionCreate;
|
||||
/** Personal connections also link the messenger account to a TaskView user. */
|
||||
identity?: { userId: number; externalUserId: string; externalTeamId: string | null };
|
||||
/** In-app path to redirect the browser to (OAuth callback). */
|
||||
redirect?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'bindByToken';
|
||||
token: string;
|
||||
scope: MessagingOwnerType;
|
||||
chatId: string;
|
||||
externalUserId: string;
|
||||
title: string | null;
|
||||
}
|
||||
| {
|
||||
/** A command from a chat (e.g. Telegram /task). The manager resolves identity + RBAC. */
|
||||
kind: 'command';
|
||||
command: 'createTask';
|
||||
text: string;
|
||||
chatId: string;
|
||||
externalUserId: string;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const MESSAGING_PROVIDERS = ['telegram', 'slack'] as const;
|
||||
export type MessagingProviderId = typeof MESSAGING_PROVIDERS[number];
|
||||
|
||||
export const MESSAGING_OWNER_TYPES = ['user', 'project', 'organization'] as const;
|
||||
export type MessagingOwnerType = typeof MESSAGING_OWNER_TYPES[number];
|
||||
|
||||
/**
|
||||
* Provider-agnostic event kinds the module reacts to. They map onto EventBus
|
||||
* events in MessagingDispatcher and onto notification types for preferences.
|
||||
*/
|
||||
/**
|
||||
* User-selectable events. Task events target the task's assignees; sprint events
|
||||
* target project members. Each connection subscribes to the subset it wants.
|
||||
*/
|
||||
export const MESSAGING_EVENTS = [
|
||||
// tasks — audience: the task's assignees (task.deleted → project members)
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.statusChanged',
|
||||
'task.completed',
|
||||
'task.edited',
|
||||
'task.addedToSprint',
|
||||
'task.deleted',
|
||||
// sprints — audience: project members
|
||||
'sprint.created',
|
||||
'sprint.updated',
|
||||
'sprint.started',
|
||||
'sprint.reviewStarted',
|
||||
'sprint.completed',
|
||||
'sprint.paused',
|
||||
'sprint.resumed',
|
||||
'sprint.deleted',
|
||||
// members — audience: project members
|
||||
'member.added',
|
||||
'member.removed',
|
||||
'member.rolesChanged',
|
||||
// time tracking — audience: project members
|
||||
'time.started',
|
||||
'time.stopped',
|
||||
'time.logged',
|
||||
'time.updated',
|
||||
'time.deleted',
|
||||
// recurring rules — audience: project members
|
||||
'recurrence.created',
|
||||
'recurrence.updated',
|
||||
'recurrence.paused',
|
||||
'recurrence.resumed',
|
||||
'recurrence.ended',
|
||||
'recurrence.deleted',
|
||||
'recurrence.skipped',
|
||||
] as const;
|
||||
export type MessagingEvent = typeof MESSAGING_EVENTS[number];
|
||||
|
||||
export function sanitizeMessagingEvents(events: string[]): MessagingEvent[] {
|
||||
const allowed = new Set<string>(MESSAGING_EVENTS);
|
||||
return [...new Set(events.filter((e) => allowed.has(e)))] as MessagingEvent[];
|
||||
}
|
||||
|
||||
/** Normalized message a provider renders into its own format. */
|
||||
export interface MessagingMessage {
|
||||
event: MessagingEvent;
|
||||
title: string;
|
||||
body?: string;
|
||||
url?: string;
|
||||
/** Present for task events — lets the Slack provider attach Done/Assign action buttons. */
|
||||
taskId?: number;
|
||||
/** An extra line rendered at the bottom (e.g. assignees) — part of the original message. */
|
||||
footer?: string;
|
||||
/** Task's current completion state — the primary button is Reopen when true, else Done. */
|
||||
completed?: boolean;
|
||||
/** Whether to render interactive action buttons (Slack). Suppressed for content-hidden channels. */
|
||||
actions?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildMessagingMessageArgs {
|
||||
event: MessagingEvent;
|
||||
audience: 'personal' | 'project';
|
||||
body: string;
|
||||
url?: string;
|
||||
taskId?: number;
|
||||
projectName?: string | null;
|
||||
footer?: string;
|
||||
completed?: boolean;
|
||||
/** Overrides the title (keeps the subscription event but shows different wording, e.g. reopened). */
|
||||
titleOverride?: string;
|
||||
/** Whether to render interactive action buttons. Defaults to shown; false for content-hidden channels. */
|
||||
actions?: boolean;
|
||||
}
|
||||
|
||||
export interface MessagingDeliverArgs {
|
||||
chatId: string;
|
||||
/** Decrypted per-connection secret (Slack bot token / webhook URL); null for Telegram (instance bot token from env). */
|
||||
accessToken: string | null;
|
||||
message: MessagingMessage;
|
||||
}
|
||||
|
||||
export interface MessagingDeliverResult {
|
||||
success: boolean;
|
||||
errorCode?: number;
|
||||
}
|
||||
|
||||
export interface MessagingDeliverJobData {
|
||||
connectionId: number;
|
||||
provider: MessagingProviderId;
|
||||
chatId: string;
|
||||
accessTokenEncrypted: string | null;
|
||||
message: MessagingMessage;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
const NumberFromString = type('string|number').pipe((v) => Number(v));
|
||||
|
||||
// Derived from MESSAGING_PROVIDERS so adding a provider doesn't silently fail validation.
|
||||
const MessagingProviderParam = type.enumerated(...MESSAGING_PROVIDERS);
|
||||
|
||||
export const MessagingArkTypeConnectLink = type({
|
||||
provider: MessagingProviderParam,
|
||||
});
|
||||
export type MessagingArgConnectLink = typeof MessagingArkTypeConnectLink.infer;
|
||||
|
||||
export const MessagingArkTypeById = type({
|
||||
id: NumberFromString,
|
||||
});
|
||||
export type MessagingArgById = typeof MessagingArkTypeById.infer;
|
||||
|
||||
export const MessagingArkTypeToggle = type({
|
||||
id: 'number',
|
||||
isActive: 'boolean',
|
||||
});
|
||||
export type MessagingArgToggle = typeof MessagingArkTypeToggle.infer;
|
||||
|
||||
export const MessagingArkTypeUpdateEvents = type({
|
||||
id: 'number',
|
||||
events: 'string[]',
|
||||
});
|
||||
export type MessagingArgUpdateEvents = typeof MessagingArkTypeUpdateEvents.infer;
|
||||
|
||||
// Project routes take goalId from IsProjectGoalOwner (res.locals), not the payload,
|
||||
// so these schemas validate only the non-authorization fields.
|
||||
export const MessagingArkTypeProviderParam = type({
|
||||
provider: MessagingProviderParam,
|
||||
});
|
||||
export type MessagingArgProviderParam = typeof MessagingArkTypeProviderParam.infer;
|
||||
|
||||
export const MessagingArkTypeProjectToggle = type({
|
||||
id: 'number',
|
||||
isActive: 'boolean',
|
||||
});
|
||||
export type MessagingArgProjectToggle = typeof MessagingArkTypeProjectToggle.infer;
|
||||
|
||||
export const MessagingArkTypeProjectDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
export type MessagingArgProjectDelete = typeof MessagingArkTypeProjectDelete.infer;
|
||||
|
||||
export const MessagingArkTypeProjectPostContent = type({
|
||||
id: 'number',
|
||||
postContent: 'boolean',
|
||||
});
|
||||
export type MessagingArgProjectPostContent = typeof MessagingArkTypeProjectPostContent.infer;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ALL_TASKS_LIST_ID } from '../../types/tasks.types';
|
||||
|
||||
// Reusable text/URL helpers shared across the messaging module.
|
||||
|
||||
// Frontend deep-link to a task: /:orgSlug/:projectId/:listId/:taskId. A task with no
|
||||
// list lives in the virtual "All tasks" list (ALL_TASKS_LIST_ID sentinel, shared with web).
|
||||
export function buildTaskDeepLink(orgSlug: string | null, goalId: number, taskId: number, goalListId: number | null): string | undefined {
|
||||
const appUrl = process.env.APP_URL;
|
||||
if (!appUrl || !orgSlug) return undefined;
|
||||
const listSegment = goalListId ?? ALL_TASKS_LIST_ID;
|
||||
return `${appUrl}/${orgSlug}/${goalId}/${listSegment}/${taskId}`;
|
||||
}
|
||||
|
||||
// Only http(s) links are ever rendered — blocks tg://, javascript:, etc.
|
||||
export function isSafeUrl(url: string): boolean {
|
||||
try {
|
||||
const scheme = new URL(url).protocol;
|
||||
return scheme === 'http:' || scheme === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
|
||||
// so a user-controlled value can't break out of a Telegram HTML message.
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Slack mrkdwn requires escaping these three in text (incl. link labels).
|
||||
export function escapeSlackText(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -22,6 +22,15 @@ export const AppEnvSchema = z.object({
|
||||
SMTP_FROM_NAME: z.string().optional(),
|
||||
SMTP_FROM_EMAIL: z.string().optional(),
|
||||
APP_URL: z.string(),
|
||||
|
||||
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
||||
TELEGRAM_BOT_USERNAME: z.string().optional(),
|
||||
TELEGRAM_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
SLACK_CALLBACK_URL: z.string().optional(),
|
||||
SLACK_SIGNING_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export const StringToNumber = z
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
---
|
||||
title: Telegram & Slack Setup
|
||||
description: Connect Telegram and Slack to TaskView to receive task notifications. Personal direct messages and project-wide group channels, with instance-level bot credentials, OAuth, signed inbound webhooks, and SHA-256 hashed binding tokens.
|
||||
navigation:
|
||||
icon: i-lucide-send
|
||||
---
|
||||
|
||||
TaskView messaging integrations deliver task events to messengers. There are two levels of connection:
|
||||
|
||||
- **Personal** — a user links their own account and receives direct messages about their tasks (assigned, deadline, etc.).
|
||||
- **Project** — an admin connects a group/channel, and events for that project are posted to it for the whole team.
|
||||
|
||||
The bot credentials are configured **per instance** (via environment variables), so the official TaskView SaaS ships an official bot and self-hosted installs supply their own — no code changes, only configuration.
|
||||
|
||||
## Notification model & event delivery
|
||||
|
||||
Understanding **who receives what** matters, because it is not "every subscriber gets every event".
|
||||
|
||||
### Connection scope
|
||||
|
||||
- **Personal** connections are keyed to a **user** (`ownerType = user`), not to a project or organization. One personal connection covers **all** your projects **and** organizations — switching the organization you view in the app does not change delivery. You receive DMs about your tasks everywhere you are a member.
|
||||
- **Project** connections are keyed to a **project** (`ownerType = project`). They post that project's events to the connected channel/group for the whole team.
|
||||
|
||||
### Per-event audience (personal delivery)
|
||||
|
||||
Each event is delivered to a specific **audience**. A subscriber receives it only if they are in that audience — the subscription checkbox controls *whether* you get an event you're eligible for, not *who* is eligible.
|
||||
|
||||
| Event | Personal recipients |
|
||||
|---|---|
|
||||
| **Task created** | **all project members** (a new task usually has no assignees yet) |
|
||||
| **Task assigned** | the task's **assignees** |
|
||||
| **Task status changed** | the task's **assignees** |
|
||||
| **Task completed / reopened** | the task's **assignees** |
|
||||
| Sprint / member / time-tracking events | the relevant project members / actors |
|
||||
|
||||
Key rules:
|
||||
|
||||
- **No initiator exclusion.** Messaging is an explicit opt-in feed, so you receive an event even for **your own** action — e.g. completing a task you're assigned to still DMs you. The initiator is not filtered out; they simply receive the event if they are in the audience.
|
||||
- **Consequence:** for everything **except Task created**, you are notified **only if you are an assignee** of that task. A task with **no assignees** produces **no** personal completed/status notification for anyone (there is no one in the audience). "Not an assignee → not notified" applies to the initiator too — they are just one non-assignee among others.
|
||||
- **Task reopened** is delivered under the **Task completed** subscription (same checkbox), shown with a `[Task reopened]` title and a ↩️→ Done button.
|
||||
|
||||
### Project delivery & content gating
|
||||
|
||||
- Project events go to the connected channel/group when the connection is **active** and **subscribed** to that event.
|
||||
- Delivery is queued (pg-boss) and retried with backoff (up to 3 attempts).
|
||||
|
||||
#### The `post content` toggle
|
||||
|
||||
A shared channel is **not** an RBAC principal — TaskView can't check the permissions of each channel member (they may not even have a TaskView account). So instead of per-viewer gating, a project connection has one explicit switch, **post content**, set by whoever connected the channel (requires `INTEGRATIONS_CAN_MANAGE`). It decides how much of a task event is broadcast:
|
||||
|
||||
| | `post content = true` (default) | `post content = false` |
|
||||
|---|---|---|
|
||||
| Task **title** + link | ✅ posted | ✅ posted |
|
||||
| Task **description** | ✅ posted | ❌ hidden |
|
||||
| **Assignee** footer (`👤 emails`) | ✅ posted | ❌ hidden |
|
||||
| **Action buttons** (Done / Reopen / Assign) | ✅ shown | ❌ hidden |
|
||||
|
||||
Turn it **off** for a channel whose audience should see only that *something happened* (title + link) without the task's content, assignee emails, or interactive actions. The event itself is still delivered either way — only the content and buttons are suppressed. Buttons are hidden on a content-hidden channel because the Assign action opens a modal listing project members, which would defeat the point of hiding content.
|
||||
|
||||
> **Personal** DMs are different: each recipient is a known TaskView user, so the description is gated per recipient by `COMPONENT_CAN_WATCH_CONTENT` and the assignee footer by `TASKS_CAN_WATCH_ASSIGNED_USERS` — and action buttons are always shown (any action still runs under that user's own RBAC).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- TaskView API running and reachable over **public HTTPS** (messengers deliver updates/redirects to your API; `localhost` is not reachable from Telegram/Slack — use your production domain or a tunnel for local testing)
|
||||
- PostgreSQL database with migrations applied
|
||||
- `.env.taskview` file configured
|
||||
|
||||
> **Local testing tunnel:** prefer `cloudflared tunnel --url http://localhost:1401` — it gives a clean HTTPS URL with no interstitial. `ngrok`'s free tier shows a "You are about to visit…" warning page that breaks the browser OAuth redirect (you can't inject the `ngrok-skip-browser-warning` header into Slack's redirect); if you must use ngrok free, open the tunnel URL once in your browser and click **Visit Site** first, or upgrade to a paid plan.
|
||||
>
|
||||
> For Slack, the API base the **frontend** calls and `SLACK_CALLBACK_URL` must be the **same HTTPS origin** — the anti-CSRF nonce cookie is set on `oauth/start` and read on the callback, so a `localhost` start + tunnel callback will not work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Migration
|
||||
|
||||
The migration creates the required tables (`tasks.messaging_connections`, `tasks.messaging_link_tokens`, `tasks.messaging_identity_map`) automatically. The migration container handles this on startup — no manual steps needed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Telegram
|
||||
|
||||
### 2.1 Create a bot
|
||||
|
||||
1. Open [@BotFather](https://t.me/BotFather) in Telegram
|
||||
2. Send `/newbot` and follow the prompts
|
||||
3. Copy the **bot token** (looks like `123456789:AA...`) and note the **bot username** (without `@`)
|
||||
|
||||
### 2.2 Configure the bot for group commands
|
||||
|
||||
Project connections are completed by typing a command **inside a group**, so the bot must be allowed to read group messages:
|
||||
|
||||
1. In @BotFather, send `/setprivacy` → choose your bot → **Disable**
|
||||
(with privacy enabled, members must instead address the bot explicitly: `/connect@your_bot <token>`)
|
||||
2. Send `/setjoingroups` → choose your bot → **Enable**
|
||||
|
||||
### 2.3 Environment variables
|
||||
|
||||
Add to `.env.taskview`:
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=123456789:AA-your-bot-token
|
||||
TELEGRAM_BOT_USERNAME=your_bot # without the leading @
|
||||
TELEGRAM_WEBHOOK_SECRET=<random-secret> # any long random string
|
||||
```
|
||||
|
||||
Generate a webhook secret:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
> The webhook secret is compared (in constant time) against the `X-Telegram-Bot-Api-Secret-Token` header on every inbound update, so only Telegram can reach the endpoint.
|
||||
|
||||
### 2.4 Register the webhook with Telegram
|
||||
|
||||
Tell Telegram where to deliver updates. Run once (replace the placeholders):
|
||||
|
||||
```bash
|
||||
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
|
||||
-d "url=https://<your-api-domain>/module/messaging/telegram/webhook" \
|
||||
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
|
||||
```
|
||||
|
||||
Verify it took effect:
|
||||
|
||||
```bash
|
||||
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
|
||||
```
|
||||
|
||||
`url` should point at your `/module/messaging/telegram/webhook` and `pending_update_count` should be `0`.
|
||||
|
||||
### 2.5 Usage
|
||||
|
||||
Restart the API after setting the environment variables, then in TaskView open a project → right-click it in the sidebar → **"Messengers"**.
|
||||
|
||||
**Personal (Direct messages)**
|
||||
|
||||
1. On the **Personal** tab, click **Connect** → **Telegram** → **Get link**
|
||||
2. Click **Open in Telegram** and press **Start** in the bot chat
|
||||
3. Your account is linked — you'll now get DMs about your tasks
|
||||
|
||||
**Project (Group channel)**
|
||||
|
||||
1. On the **Project** tab (requires the `INTEGRATIONS_CAN_MANAGE` permission), click **Connect** → **Telegram** → **Get link**
|
||||
2. Click **Add bot to group** and pick the group, or add the bot manually
|
||||
3. In that group, send the shown command: `/connect <token>`
|
||||
4. The group is connected — project events are now posted there
|
||||
|
||||
You can toggle any connection on/off or disconnect it from the same page.
|
||||
|
||||
**Creating tasks from a group (`/task`)**
|
||||
|
||||
In a group linked to **exactly one** project, any member can create a task:
|
||||
|
||||
```
|
||||
/task Buy the domain
|
||||
```
|
||||
|
||||
- Requires the sender to have **linked their personal Telegram account** (Personal tab → Connect → Telegram) — the command runs under their TaskView permissions (`COMPONENT_CAN_ADD_TASKS`).
|
||||
- The bot replies with the created task and a link. In an unlinked chat, or one linked to several projects, it says so and creates nothing.
|
||||
- **Privacy mode note:** with the bot's group privacy **enabled**, Telegram only delivers commands addressed to the bot — use `/task@your_bot <description>` (same as `/connect`). Disable privacy (and re-add the bot) to allow a bare `/task`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Slack
|
||||
|
||||
Slack uses per-instance OAuth credentials, mirroring the GitHub/GitLab integration model. Requires `ENCRYPTION_KEY` (tokens are stored AES-256-GCM encrypted).
|
||||
|
||||
> **Public HTTPS is required.** Slack must reach your API over HTTPS for OAuth, slash
|
||||
> commands and interactivity. In local dev use a tunnel (ngrok / cloudflared); note that a
|
||||
> free ngrok URL changes on every restart — if it changes you must update the Redirect URL,
|
||||
> the Slash Command / Interactivity Request URLs **and** `SLACK_CALLBACK_URL`, then restart.
|
||||
|
||||
### 3.1 Create the app
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch** → name it (e.g. `TaskView`) and pick your workspace.
|
||||
|
||||
2. **App name vs bot name** (these are two different fields — a common point of confusion):
|
||||
- **Basic Information → Display Information → App name** — the app's name in the api.slack.com console (the dropdown at the top-left). Purely cosmetic for the console.
|
||||
- **App Home → App Display Name → Edit → Display Name (Bot Name)** — how the **bot** signs its messages in Slack (e.g. `TaskView`). Changing it only takes effect after a **Reinstall** (step 5).
|
||||
|
||||
### 3.2 OAuth & scopes
|
||||
|
||||
1. **OAuth & Permissions → Redirect URLs → Add New Redirect URL** — enter exactly your `SLACK_CALLBACK_URL`, then **Save URLs**:
|
||||
```
|
||||
https://<your-api-domain>/module/messaging/slack/oauth/callback
|
||||
```
|
||||
Slack rejects plain `http://localhost` — HTTPS only.
|
||||
|
||||
2. **OAuth & Permissions → Scopes → Bot Token Scopes** — add:
|
||||
- `chat:write` — post messages (personal DMs + project channels). **Required.**
|
||||
- `commands` — needed for the `/task` slash command (inbound).
|
||||
- `chat:write.public` — *optional but recommended*: lets the bot post to **any public channel without being invited**. Without it you must `/invite` the bot into each channel (see step 5).
|
||||
- `im:write` — *optional*, only if personal DMs fail to open.
|
||||
> `incoming-webhook` is added automatically when you enable Incoming Webhooks (step 3.3) and is what makes the project-connect channel picker appear.
|
||||
|
||||
### 3.3 Features
|
||||
|
||||
1. **Incoming Webhooks → Activate Incoming Webhooks: On.** Required for the **project (channel)** connect flow — it makes Slack show a channel picker during install.
|
||||
|
||||
2. **Slash Commands → Create New Command:**
|
||||
- Command: `/task`
|
||||
- Request URL: `https://<your-api-domain>/module/messaging/slack/commands`
|
||||
- Short description: `Create a TaskView task`
|
||||
|
||||
3. **Interactivity & Shortcuts → On**, Request URL:
|
||||
```
|
||||
https://<your-api-domain>/module/messaging/slack/interactivity
|
||||
```
|
||||
Powers the **Done / Reopen / Assign** buttons and the assignee modal.
|
||||
|
||||
### 3.4 Credentials → environment variables
|
||||
|
||||
**Basic Information → App Credentials** — copy the three values:
|
||||
|
||||
```
|
||||
SLACK_CLIENT_ID=<your-client-id>
|
||||
SLACK_CLIENT_SECRET=<your-client-secret>
|
||||
SLACK_CALLBACK_URL=https://<your-api-domain>/module/messaging/slack/oauth/callback
|
||||
SLACK_SIGNING_SECRET=<your-signing-secret>
|
||||
```
|
||||
|
||||
- `SLACK_CALLBACK_URL` must **exactly** match the Redirect URL from step 3.2.
|
||||
- `SLACK_SIGNING_SECRET` verifies inbound slash commands / interactivity (without it those endpoints return 503).
|
||||
- Tokens are stored AES-256-GCM encrypted — `ENCRYPTION_KEY` is required.
|
||||
- Optional endpoint overrides (tests / enterprise proxy): `SLACK_API_BASE_URL`, `SLACK_AUTHORIZE_URL`, `SLACK_WEBHOOK_PREFIX` (see the environment-variables reference).
|
||||
|
||||
Then **restart the API.**
|
||||
|
||||
### 3.5 Install (and reinstall)
|
||||
|
||||
1. **OAuth & Permissions → Install to Workspace** (or *Reinstall to Workspace*).
|
||||
2. **Reinstall whenever you change scopes** or the bot display name — otherwise changes don't take effect.
|
||||
3. **Invite the bot into the channel(s)** it should post to:
|
||||
```
|
||||
/invite @TaskView
|
||||
```
|
||||
`chat.postMessage` fails with `not_in_channel` if the bot isn't a member. Either invite it per channel, or add the `chat:write.public` scope (step 3.2) so it can post to any **public** channel without an invite (private channels always need an invite).
|
||||
|
||||
### 3.6 Connect and use
|
||||
|
||||
Open **Messengers** in a project:
|
||||
|
||||
- **Personal:** Personal tab → **Connect** → **Slack** → **Continue with Slack** → authorize. The bot DMs you about your tasks. This also **links your Slack account** (required for inbound actions to run as you).
|
||||
- **Project:** Project tab (needs `INTEGRATIONS_CAN_MANAGE`) → **Connect** → **Slack** → **Continue with Slack** → pick the channel during install. Project events post to that channel. **Invite the bot into that channel** (step 3.5).
|
||||
|
||||
> **Gotcha — "added an integration to this channel" is NOT enough.** After you pick a channel during install, Slack posts *"added an integration to this channel: TaskView"* there. That only adds the app's incoming webhook — it does **not** make the bot **user** a channel member, and TaskView posts via the bot (`chat.postMessage`, so it can also render buttons). So delivery still fails with `not_in_channel` until you explicitly run **`/invite @TaskView`** in that channel (or use `chat:write.public`). Confirm the connection's channel is the one you meant — the `#channel` you picked in the webhook dropdown is where events go.
|
||||
|
||||
**Acting from Slack:**
|
||||
- `/task <description>` in a channel linked to **exactly one** project → creates a task there. (In a DM or an unlinked channel it replies that the channel isn't linked; a channel linked to several projects is ambiguous and is refused — create it in the app.)
|
||||
- Under each task notification: **✅ Done** (complete), **↩️ Reopen** (on a completed card), **👤 Assign** (a modal that pre-selects current assignees; add/remove and Save to set the list).
|
||||
- Every inbound action runs under the invoking user's TaskView RBAC, so they must have linked their Slack account. Requests are verified with the Signing Secret.
|
||||
|
||||
> **Notification model:** messengers are a **feed/history** — each event posts a new message. TaskView does **not** edit past cards, so an old card may still show a live button after the task changed elsewhere. Actions are idempotent and always re-check the current state + permissions, so a stale click is safe.
|
||||
|
||||
### 3.7 Distribution
|
||||
|
||||
By default a Slack app is **private to your workspace**. To let other workspaces install it, enable **Public Distribution** (free); to list it in the Slack Marketplace, pass Slack's review (also free — a review, not a fee). Self-hosted installs keep the app private; the official SaaS uses one distributed app. Creating and distributing an app does **not** require a paid Slack plan.
|
||||
|
||||
---
|
||||
|
||||
## 4. Full `.env.taskview` Example
|
||||
|
||||
```env
|
||||
# ... existing vars ...
|
||||
|
||||
# Telegram messaging integration
|
||||
TELEGRAM_BOT_TOKEN=123456789:AA-your-bot-token
|
||||
TELEGRAM_BOT_USERNAME=your_bot
|
||||
TELEGRAM_WEBHOOK_SECRET=a1b2c3d4e5f6...
|
||||
|
||||
# Slack messaging integration
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
SLACK_CALLBACK_URL=https://api.taskview.tech/module/messaging/slack/oauth/callback
|
||||
SLACK_SIGNING_SECRET=
|
||||
|
||||
# Required for Slack (encrypted token storage), shared with SSO/other integrations
|
||||
ENCRYPTION_KEY=a1b2c3d4e5f6... # 64 hex characters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. SaaS vs Self-Hosted
|
||||
|
||||
| | Official SaaS | Self-hosted |
|
||||
|---|---|---|
|
||||
| **Telegram** | One official bot, credentials shipped with the instance | Create your own bot via @BotFather and set `TELEGRAM_*` |
|
||||
| **Slack** | One official distributed app | Create your own Slack app and set `SLACK_*` |
|
||||
|
||||
There is no code fork between the two — only the values of the environment variables differ. If the variables are unset, the integration simply does not appear until an admin configures it.
|
||||
|
||||
---
|
||||
|
||||
## 6. Production Notes
|
||||
|
||||
- **Public HTTPS is mandatory** for the inbound webhook. Update the `setWebhook` URL to your production domain.
|
||||
- **Re-run `setWebhook`** whenever the API domain changes.
|
||||
- **`TELEGRAM_WEBHOOK_SECRET`**: store securely, never commit to git.
|
||||
- **Group privacy**: if `/connect` in a group does nothing, the bot's privacy mode is likely still enabled (see step 2.2).
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
**The "Messengers" page shows a 503 / "This messenger is not configured on the server"**
|
||||
→ `TELEGRAM_BOT_TOKEN` or `TELEGRAM_BOT_USERNAME` is missing; the API was not restarted after setting them.
|
||||
|
||||
**Pressing Start / sending `/connect` does nothing**
|
||||
→ The webhook is not registered or points at the wrong URL. Check `getWebhookInfo` (step 2.4) and confirm the API is reachable over public HTTPS.
|
||||
|
||||
**`/connect <token>` in a group is ignored**
|
||||
→ The bot's privacy mode is enabled. Disable it (step 2.2), or use `/connect@your_bot <token>`.
|
||||
|
||||
**"Link is invalid or expired"**
|
||||
→ Binding tokens are single-use and expire after 15 minutes. Generate a new one from the **Connect** dialog.
|
||||
|
||||
**Notifications don't arrive after connecting**
|
||||
→ Confirm the connection is toggled **on**, and that you are actually assigned to the task. Project posts require an active project connection.
|
||||
|
||||
### Slack
|
||||
|
||||
**Nothing posts to a linked channel (personal DM works)** — the log shows `chat.postMessage failed: not_in_channel`
|
||||
→ The bot **user** isn't a member of that channel. Seeing *"added an integration to this channel: TaskView"* is **not** the same as the bot joining — that message is just the incoming webhook. Run **`/invite @TaskView`** (the bot user) in the channel, or add the `chat:write.public` scope and reinstall. The log line names the channel id (`channel=…`) — make sure it's the one you intended.
|
||||
|
||||
**`/task` replies "This channel is not linked to a TaskView project"**
|
||||
→ Run it in a **project channel** (connected via Messengers → Project), not in a DM. A channel linked to **several** projects is refused as ambiguous — create the task in the app.
|
||||
|
||||
**Slash command / buttons return an error or nothing**
|
||||
→ `SLACK_SIGNING_SECRET` is missing (endpoints return 503), the Request URLs don't match your current public host, or the app wasn't reinstalled after enabling `commands` / `chat:write`.
|
||||
|
||||
**"You need to link your Slack account"**
|
||||
→ Inbound actions run under your TaskView permissions. Link it: Personal tab → Connect → Slack.
|
||||
|
||||
**Bot still shows the old name in messages**
|
||||
→ Changing **Display Name (Bot Name)** requires **Reinstall to Workspace**. The console dropdown name is a separate field (Basic Information → App name).
|
||||
@@ -0,0 +1,83 @@
|
||||
@startuml messaging-slack
|
||||
title TaskView — Slack messaging integration (sequence)
|
||||
|
||||
actor User
|
||||
participant "Web\n(frontend)" as Web
|
||||
participant "Slack" as Slack
|
||||
participant "API routes\n+ controller" as API
|
||||
participant "MessagingManager /\nSlackInboundManager" as Mgr
|
||||
participant "SlackProvider" as Prov
|
||||
participant "MessagingDispatcher" as Disp
|
||||
participant "EventBus /\nTasksManager" as Core
|
||||
queue "pg-boss" as Q
|
||||
database "PostgreSQL" as DB
|
||||
|
||||
== Connect (OAuth) ==
|
||||
User -> Web: Messengers → Connect Slack
|
||||
Web -> API: GET /slack/oauth/start (?token, returnPath)
|
||||
API -> Mgr: startOAuthConnect(ctx)
|
||||
Mgr -> Prov: startConnect(ctx)
|
||||
Prov --> API: url + anti-CSRF nonce cookie
|
||||
API --> User: 302 → Slack authorize
|
||||
User -> Slack: authorize (project: pick a channel)
|
||||
Slack -> API: GET /slack/oauth/callback (code, state)
|
||||
API -> Mgr: handleInbound(source=oauth-callback, cookie=nonce)
|
||||
Mgr -> Prov: parseInbound → verify state+nonce, exchange code
|
||||
Prov -> Slack: oauth.v2.access
|
||||
Slack --> Prov: bot token, team, channel
|
||||
Prov --> Mgr: {createConnection, identity?}
|
||||
Mgr -> DB: store connection (encrypted bot token)\n(+ identity_map for personal)
|
||||
API --> User: 302 back (?messaging=connected)
|
||||
|
||||
== Outbound notification ==
|
||||
User -> Core: create / assign / complete a task
|
||||
Core -> Disp: EventBus emit task.*
|
||||
Disp -> DB: resolve recipients + subscribed connections
|
||||
note right of Disp: description RBAC-gated per recipient\n(COMPONENT_CAN_WATCH_CONTENT)
|
||||
Disp -> Q: enqueue deliver jobs
|
||||
Q -> Disp: worker → deliverJob
|
||||
Disp -> Prov: deliver(message)
|
||||
Prov -> Slack: chat.postMessage (Block Kit + buttons)
|
||||
Slack --> User: notification card
|
||||
alt delivery fails
|
||||
Disp -> Q: re-enqueue (backoff, max 3)
|
||||
end
|
||||
|
||||
== Inbound: /task ==
|
||||
User -> Slack: /task <text> (project channel)
|
||||
Slack -> API: POST /slack/commands
|
||||
API -> API: VerifySlackRequest (HMAC signature + replay window)
|
||||
API -> Mgr: handleSlashCommand(payload)
|
||||
Mgr -> DB: identity (slack user → userId)
|
||||
Mgr -> DB: channel → project (goalId)
|
||||
Mgr -> Core: RBAC (COMPONENT_CAN_ADD_TASKS) + addTaskNew
|
||||
Core --> Mgr: created task
|
||||
Mgr --> Slack: ephemeral "✅ Task created: <text>" (no link)
|
||||
Slack --> User: ephemeral reply
|
||||
|
||||
== Inbound: Done / Reopen / Assign buttons ==
|
||||
User -> Slack: click Done / Reopen / Assign
|
||||
Slack -> API: POST /slack/interactivity
|
||||
API -> API: VerifySlackRequest (signature)
|
||||
API -> Mgr: handleInteraction(payload)
|
||||
Mgr -> DB: identity → userId
|
||||
alt Assign
|
||||
Mgr -> Slack: views.open (multi-select modal, current assignees pre-selected)
|
||||
User -> Slack: pick assignees → Save
|
||||
Slack -> API: POST /slack/interactivity (view_submission)
|
||||
API -> Mgr: handleAssignSubmit
|
||||
Mgr -> Core: RBAC (TASKS_CAN_ASSIGN_USERS) + toggleTaskUsers (set)
|
||||
Mgr --> Slack: {} → modal closes (no ephemeral)
|
||||
else Done / Reopen
|
||||
Mgr -> Core: RBAC (TASKS_CAN_EDIT_STATUS) + updateTask(complete)
|
||||
Mgr --> Slack: ephemeral ack (response_url, replace_original=false)
|
||||
Slack --> User: private confirmation
|
||||
end
|
||||
|
||||
note over User, DB
|
||||
Feed model: each event posts a NEW card.
|
||||
Past cards are not edited; a stale click is safe
|
||||
(idempotent action + RBAC re-check).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,75 @@
|
||||
@startuml messaging-telegram
|
||||
title TaskView — Telegram messaging integration (sequence)
|
||||
|
||||
actor User
|
||||
participant "Web\n(frontend)" as Web
|
||||
participant "Telegram" as TG
|
||||
participant "API routes\n+ controller" as API
|
||||
participant "MessagingManager" as Mgr
|
||||
participant "TelegramProvider" as Prov
|
||||
participant "MessagingDispatcher" as Disp
|
||||
participant "EventBus /\nTasksManager" as Core
|
||||
queue "pg-boss" as Q
|
||||
database "PostgreSQL" as DB
|
||||
|
||||
== One-time setup (admin) ==
|
||||
note over API, TG
|
||||
Admin runs setWebhook(url, secret_token) once.
|
||||
Telegram then POSTs every update to
|
||||
/module/messaging/telegram/webhook.
|
||||
end note
|
||||
|
||||
== Connect (deep-link binding) ==
|
||||
User -> Web: Messengers → Connect Telegram → Get link
|
||||
Web -> API: GET /:provider/connect-link (personal or project)
|
||||
API -> Mgr: createPersonalConnectLink / createProjectConnectLink
|
||||
Mgr -> Prov: startConnect(ctx)
|
||||
Prov --> Mgr: t.me deep-link + one-time token
|
||||
Mgr -> DB: store hashed link token (TTL 15 min)
|
||||
API --> User: deep-link ("Add bot to group") + /connect <token>
|
||||
User -> TG: /start <token> (private) or /connect <token> (group)
|
||||
TG -> API: POST /telegram/webhook
|
||||
API -> API: VerifyTelegramWebhook (secret token, constant-time)
|
||||
API -> Mgr: handleInbound(source=webhook)
|
||||
Mgr -> Prov: parseInbound → {bindByToken, scope}
|
||||
Mgr -> DB: findValidLinkToken → create connection\n(+ identity_map for personal), consume token
|
||||
Mgr -> Prov: deliver("Done! connected")
|
||||
Prov -> TG: sendMessage
|
||||
TG --> User: confirmation
|
||||
|
||||
== Outbound notification ==
|
||||
User -> Core: create / assign / complete a task
|
||||
Core -> Disp: EventBus emit task.*
|
||||
Disp -> DB: resolve recipients + subscribed connections
|
||||
note right of Disp: description RBAC-gated per recipient\n(COMPONENT_CAN_WATCH_CONTENT)
|
||||
Disp -> Q: enqueue deliver jobs
|
||||
Q -> Disp: worker → deliverJob
|
||||
Disp -> Prov: deliver(message)
|
||||
Prov -> TG: sendMessage (HTML, link on "[event] [project]")
|
||||
TG --> User: notification
|
||||
alt delivery fails
|
||||
Disp -> Q: re-enqueue (backoff, max 3)
|
||||
end
|
||||
|
||||
== Inbound: /task ==
|
||||
User -> TG: /task <text> (group; /task@bot if privacy is on)
|
||||
TG -> API: POST /telegram/webhook
|
||||
API -> API: VerifyTelegramWebhook (secret token)
|
||||
API -> Mgr: handleInbound(source=webhook)
|
||||
Mgr -> Prov: parseInbound → {command: createTask}
|
||||
Mgr -> DB: identity (telegram user → userId)
|
||||
Mgr -> DB: group chat → project (goalId)
|
||||
Mgr -> Core: RBAC (COMPONENT_CAN_ADD_TASKS) + addTaskNew
|
||||
Core --> Mgr: created task
|
||||
Mgr -> Prov: deliver("Task created" + link)
|
||||
Prov -> TG: sendMessage
|
||||
TG --> User: reply
|
||||
|
||||
note over User, DB
|
||||
Telegram = notifications + binding + /task.
|
||||
No inline buttons (Done/Assign) — that is Slack-only.
|
||||
The link is always sent; Telegram itself won't render
|
||||
a localhost href (needs a public APP_URL).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -98,6 +98,23 @@ For connecting GitLab repositories.
|
||||
| `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab |
|
||||
| `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API |
|
||||
|
||||
## Messaging Integrations (Telegram / Slack)
|
||||
|
||||
For delivering task notifications to messengers. See [Telegram & Slack Setup](/docs/integrations/messaging) for a step-by-step guide.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `TELEGRAM_BOT_TOKEN` | No | - | Bot token from @BotFather |
|
||||
| `TELEGRAM_BOT_USERNAME` | No | - | Bot username without `@` (used to build deep-links) |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | No | - | Secret verified against Telegram's `X-Telegram-Bot-Api-Secret-Token` header on inbound updates |
|
||||
| `SLACK_CLIENT_ID` | No | - | Slack app client ID |
|
||||
| `SLACK_CLIENT_SECRET` | No | - | Slack app client secret |
|
||||
| `SLACK_CALLBACK_URL` | No | - | OAuth callback URL (must match the Slack app redirect URL) |
|
||||
| `SLACK_SIGNING_SECRET` | No | - | Slack request signing secret — verifies inbound slash commands / interactivity (`/task`, Done/Assign buttons) |
|
||||
| `SLACK_API_BASE_URL` | No | `https://slack.com/api` | Override the Slack Web API base (testing / enterprise proxy) |
|
||||
| `SLACK_AUTHORIZE_URL` | No | `https://slack.com/oauth/v2/authorize` | Override the Slack OAuth authorize URL |
|
||||
| `SLACK_WEBHOOK_PREFIX` | No | `https://hooks.slack.com/` | Override the incoming-webhook URL prefix (used to detect legacy webhook connections) |
|
||||
|
||||
## Notifications
|
||||
|
||||
Optional configuration for real-time and push notification delivery.
|
||||
@@ -227,6 +244,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oa
|
||||
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Messaging integrations
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_BOT_USERNAME=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
# SLACK_CLIENT_ID=
|
||||
# SLACK_CLIENT_SECRET=
|
||||
# SLACK_SIGNING_SECRET=
|
||||
|
||||
# Notifications (optional)
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
# CENTRIFUGO_API_URL=http://centrifugo:8000
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import TvApiBase from './base';
|
||||
import type { AppResponse } from '@/api/base.types';
|
||||
import type {
|
||||
MessagingArgDelete,
|
||||
MessagingArgProjectDelete,
|
||||
MessagingArgProjectToggle,
|
||||
MessagingArgToggle,
|
||||
MessagingConnectLinkResult,
|
||||
MessagingConnectionItem,
|
||||
MessagingProviderId,
|
||||
} from './messaging.types';
|
||||
|
||||
export default class TvMessagingApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/messaging';
|
||||
|
||||
public async fetch() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<MessagingConnectionItem[]>>(`${this.moduleUrl}`)
|
||||
);
|
||||
}
|
||||
|
||||
public async connectLink(provider: MessagingProviderId) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<MessagingConnectLinkResult>>(`${this.moduleUrl}/${provider}/connect-link`)
|
||||
);
|
||||
}
|
||||
|
||||
public async toggle(data: MessagingArgToggle) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/toggle`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async updateEvents(data: { id: number; events: string[] }) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/events`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(data: MessagingArgDelete) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}`, { data })
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchProject(goalId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<MessagingConnectionItem[]>>(`${this.moduleUrl}/project`, { params: { goalId } })
|
||||
);
|
||||
}
|
||||
|
||||
public async projectConnectLink(provider: MessagingProviderId, goalId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<MessagingConnectLinkResult>>(`${this.moduleUrl}/project/${provider}/connect-link`, { params: { goalId } })
|
||||
);
|
||||
}
|
||||
|
||||
public async toggleProject(data: MessagingArgProjectToggle) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/project/toggle`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async updateProjectEvents(data: { id: number; goalId: number; events: string[] }) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/project/events`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async updateProjectPostContent(data: { id: number; goalId: number; postContent: boolean }) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/project/post-content`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteProject(data: MessagingArgProjectDelete) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/project`, { data })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export type MessagingProviderId = 'telegram' | 'slack';
|
||||
|
||||
export type MessagingOwnerType = 'user' | 'project' | 'organization';
|
||||
|
||||
export const MESSAGING_EVENTS = [
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.statusChanged',
|
||||
'task.completed',
|
||||
'task.edited',
|
||||
'task.addedToSprint',
|
||||
'task.deleted',
|
||||
'sprint.created',
|
||||
'sprint.updated',
|
||||
'sprint.started',
|
||||
'sprint.reviewStarted',
|
||||
'sprint.completed',
|
||||
'sprint.paused',
|
||||
'sprint.resumed',
|
||||
'sprint.deleted',
|
||||
'member.added',
|
||||
'member.removed',
|
||||
'member.rolesChanged',
|
||||
'time.started',
|
||||
'time.stopped',
|
||||
'time.logged',
|
||||
'time.updated',
|
||||
'time.deleted',
|
||||
'recurrence.created',
|
||||
'recurrence.updated',
|
||||
'recurrence.paused',
|
||||
'recurrence.resumed',
|
||||
'recurrence.ended',
|
||||
'recurrence.deleted',
|
||||
'recurrence.skipped',
|
||||
] as const;
|
||||
export type MessagingEvent = typeof MESSAGING_EVENTS[number];
|
||||
|
||||
export type MessagingConnectionItem = {
|
||||
id: number;
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
targetChatId: string;
|
||||
title: string | null;
|
||||
externalTeamId: string | null;
|
||||
events: string[];
|
||||
postContent: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type MessagingConnectLinkResult = {
|
||||
provider: MessagingProviderId;
|
||||
url: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type MessagingArgToggle = {
|
||||
id: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type MessagingArgDelete = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type MessagingArgProjectToggle = {
|
||||
id: number;
|
||||
goalId: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type MessagingArgProjectDelete = {
|
||||
id: number;
|
||||
goalId: number;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export * from '@/api/kanban.types';
|
||||
export * from '@/api/integrations.types';
|
||||
export * from '@/api/notifications.api.types';
|
||||
export * from '@/api/webhooks.types';
|
||||
export * from '@/api/messaging.types';
|
||||
export * from '@/api/api-tokens.types';
|
||||
export * from '@/api/sessions.types';
|
||||
export * from '@/api/organizations.types';
|
||||
|
||||
@@ -9,6 +9,7 @@ import TvIntegrationsApi from "./api/integrations";
|
||||
import TvKanban from "./api/kanban";
|
||||
import TvNotificationsApi from "./api/notifications";
|
||||
import TvWebhooks from "./api/webhooks";
|
||||
import TvMessagingApi from "./api/messaging";
|
||||
import TvApiTokens from "./api/api-tokens";
|
||||
import TvSessions from "./api/sessions";
|
||||
import TvOrganizationsApi from "./api/organizations";
|
||||
@@ -43,6 +44,8 @@ export class TvApi {
|
||||
|
||||
public webhooks: TvWebhooks;
|
||||
|
||||
public messaging: TvMessagingApi;
|
||||
|
||||
public apiTokens: TvApiTokens;
|
||||
|
||||
public sessions: TvSessions;
|
||||
@@ -84,6 +87,8 @@ export class TvApi {
|
||||
|
||||
this.webhooks = new TvWebhooks(this.$axios);
|
||||
|
||||
this.messaging = new TvMessagingApi(this.$axios);
|
||||
|
||||
this.apiTokens = new TvApiTokens(this.$axios);
|
||||
|
||||
this.sessions = new TvSessions(this.$axios);
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './schemas/notifications.schema';
|
||||
export * from './schemas/device-tokens.schema';
|
||||
export * from './schemas/notification-preferences.schema';
|
||||
export * from './schemas/webhooks.schema';
|
||||
export * from './schemas/messaging.schema';
|
||||
export * from './schemas/api-tokens.schema';
|
||||
export * from './schemas/user-tokens.schema';
|
||||
export * from './schemas/organizations.schema';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { integer, pgSchema, time, varchar } from "drizzle-orm/pg-core";
|
||||
import { integer, pgSchema, time, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
import { GoalsSchema } from "./goals.schema";
|
||||
import { PermissionsSchema } from "./users.schema";
|
||||
|
||||
@@ -26,7 +26,9 @@ export const CollaborationPermissionsToRoleSchema = pgSchema('collaboration').ta
|
||||
export const CollaborationUsersToGoalsSchema = pgSchema('collaboration').table('users_to_goals', {
|
||||
userId: integer('user_id').notNull().references(() => CollaborationUsersSchema.id, { onDelete: 'cascade' }),
|
||||
goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
}, (table) => [
|
||||
uniqueIndex('users_to_goals_user_goal_uidx').on(table.userId, table.goalId),
|
||||
]);
|
||||
|
||||
export const CollaborationUsersToRolesSchema = pgSchema('collaboration').table('users_to_roles', {
|
||||
userId: integer('user_id').notNull().references(() => CollaborationUsersSchema.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { boolean, integer, pgSchema, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export const MessagingConnectionsSchema = pgSchema('tasks').table('messaging_connections', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
provider: varchar({ length: 20 }).notNull(),
|
||||
ownerType: varchar('owner_type', { length: 20 }).notNull(),
|
||||
ownerId: integer('owner_id').notNull(),
|
||||
targetChatId: varchar('target_chat_id', { length: 255 }).notNull(),
|
||||
title: varchar({ length: 255 }),
|
||||
externalTeamId: varchar('external_team_id', { length: 255 }),
|
||||
accessTokenEncrypted: varchar('access_token_encrypted'),
|
||||
events: varchar().array().notNull().default(['task.created', 'task.assigned', 'task.statusChanged', 'task.completed']),
|
||||
// Project channels only: whether to include the RBAC-gated task description in the
|
||||
// channel message. Default on — a channel is a deliberate broadcast; owners who care
|
||||
// about COMPONENT_CAN_WATCH_CONTENT turn it off. Personal DMs always gate per-recipient.
|
||||
postContent: boolean('post_content').notNull().default(true),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
}, (table) => [
|
||||
uniqueIndex('messaging_connection_unique').on(table.provider, table.ownerType, table.ownerId, table.targetChatId),
|
||||
]);
|
||||
|
||||
// Pending binding intent for any owner (user / project / organization). A user
|
||||
// generates a token in TaskView, then redeems it from the messenger (deep-link
|
||||
// for personal, /connect in a group for project). Consumed on redemption.
|
||||
export const MessagingLinkTokensSchema = pgSchema('tasks').table('messaging_link_tokens', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
token: varchar({ length: 128 }).notNull().unique(),
|
||||
provider: varchar({ length: 20 }).notNull(),
|
||||
ownerType: varchar('owner_type', { length: 20 }).notNull(),
|
||||
ownerId: integer('owner_id').notNull(),
|
||||
createdBy: integer('created_by').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
});
|
||||
|
||||
// Pure identity: which external account belongs to which TaskView user. Populated
|
||||
// when a personal connection is bound; reused later to resolve inbound commands.
|
||||
export const MessagingIdentityMapSchema = pgSchema('tasks').table('messaging_identity_map', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
|
||||
provider: varchar({ length: 20 }).notNull(),
|
||||
externalUserId: varchar('external_user_id', { length: 255 }),
|
||||
// Slack user IDs are unique only WITHIN a workspace, so identity must be keyed by
|
||||
// (provider, team, user). Null for Telegram, whose user IDs are globally unique.
|
||||
externalTeamId: varchar('external_team_id', { length: 255 }),
|
||||
linkedAt: timestamp('linked_at'),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
}, (table) => [
|
||||
uniqueIndex('messaging_identity_unique').on(table.userId, table.provider),
|
||||
]);
|
||||
|
||||
export type MessagingConnectionsSchemaTypeForSelect = typeof MessagingConnectionsSchema.$inferSelect;
|
||||
export type MessagingConnectionsSchemaTypeForInsert = typeof MessagingConnectionsSchema.$inferInsert;
|
||||
export type MessagingLinkTokensSchemaTypeForSelect = typeof MessagingLinkTokensSchema.$inferSelect;
|
||||
export type MessagingIdentityMapSchemaTypeForSelect = typeof MessagingIdentityMapSchema.$inferSelect;
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabs"
|
||||
class="w-full"
|
||||
:ui="{ list: 'rounded-2xl', trigger: 'rounded-xl', indicator: 'rounded-xl' }"
|
||||
>
|
||||
<template #personal>
|
||||
<div class="flex items-center justify-between mb-4 mt-2">
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('messaging.personalHint') }}
|
||||
</p>
|
||||
<UButton
|
||||
:label="t('messaging.connect')"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
@click="openConnect('user')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.personalLoading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="store.personal.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-send"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('messaging.personalEmpty') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<MessagingConnectionItem
|
||||
v-for="item in store.personal"
|
||||
:key="item.id"
|
||||
:connection="item"
|
||||
:can-manage="true"
|
||||
@toggle="(isActive) => store.togglePersonal(item.id, isActive)"
|
||||
@delete="handleDeletePersonal(item.id)"
|
||||
@update-events="(events) => store.updatePersonalEvents(item.id, events)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #project>
|
||||
<div
|
||||
v-if="!hasProject"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-folder"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('messaging.selectProject') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!canViewIntegrations"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-lock"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('messaging.noAccess') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-4 mt-2">
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('messaging.projectHint') }}
|
||||
</p>
|
||||
<UButton
|
||||
v-if="canManageIntegrations"
|
||||
:label="t('messaging.connect')"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
@click="openConnect('project')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.projectLoading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="store.project.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-send"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('messaging.projectEmpty') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<MessagingConnectionItem
|
||||
v-for="item in store.project"
|
||||
:key="item.id"
|
||||
:connection="item"
|
||||
:can-manage="canManageIntegrations"
|
||||
@toggle="(isActive) => store.toggleProject(item.id, projectId, isActive)"
|
||||
@delete="handleDeleteProject(item.id)"
|
||||
@update-events="(events) => store.updateProjectEvents(item.id, projectId, events)"
|
||||
@update-post-content="(postContent) => store.updateProjectPostContent(item.id, projectId, postContent)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</UTabs>
|
||||
|
||||
<MessagingConnectModal
|
||||
v-model:open="isConnectOpen"
|
||||
:scope="connectScope"
|
||||
:goal-id="projectId"
|
||||
@connected="refresh"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessagingStore } from '@/stores/messaging.store'
|
||||
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import MessagingConnectionItem from './parts/MessagingConnectionItem.vue'
|
||||
import MessagingConnectModal from './parts/MessagingConnectModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMessagingStore()
|
||||
const { projectId, hasProject } = useAppRouteInfo()
|
||||
const { canViewIntegrations, canManageIntegrations } = useGoalPermissions()
|
||||
|
||||
const activeTab = ref('project')
|
||||
const isConnectOpen = ref(false)
|
||||
const connectScope = ref<'user' | 'project'>('user')
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ label: t('messaging.tabs.project'), slot: 'project', value: 'project' },
|
||||
{ label: t('messaging.tabs.personal'), slot: 'personal', value: 'personal' },
|
||||
])
|
||||
|
||||
function openConnect(scope: 'user' | 'project') {
|
||||
connectScope.value = scope
|
||||
isConnectOpen.value = true
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (connectScope.value === 'project') fetchProject()
|
||||
else store.fetchPersonal()
|
||||
}
|
||||
|
||||
function fetchProject() {
|
||||
if (hasProject.value && canViewIntegrations.value) store.fetchProject(projectId.value)
|
||||
}
|
||||
|
||||
async function handleDeletePersonal(id: number) {
|
||||
await store.deletePersonal(id)
|
||||
}
|
||||
|
||||
async function handleDeleteProject(id: number) {
|
||||
await store.deleteProject(id, projectId.value)
|
||||
}
|
||||
|
||||
onMounted(() => store.fetchPersonal())
|
||||
// Also react to canViewIntegrations: on a cold page load the goal permissions arrive
|
||||
// after mount, so watching projectId alone would fetch before we're allowed and never retry.
|
||||
watch([projectId, canViewIntegrations], fetchProject, { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:fullscreen="isMobile"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('messaging.connectTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
v-if="step === 'select'"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('messaging.connectDescription') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 p-4 border rounded-lg transition-colors"
|
||||
:class="provider === 'telegram' ? 'border-primary bg-primary/5' : 'border-default hover:border-primary'"
|
||||
@click="provider = 'telegram'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-send"
|
||||
class="size-8 text-primary"
|
||||
/>
|
||||
<span class="font-medium">{{ t('messaging.telegram') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 p-4 border rounded-lg transition-colors"
|
||||
:class="provider === 'slack' ? 'border-primary bg-primary/5' : 'border-default hover:border-primary'"
|
||||
@click="provider = 'slack'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-slack"
|
||||
class="size-8 text-primary"
|
||||
/>
|
||||
<span class="font-medium">{{ t('messaging.slack') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{{ scope === 'project' ? t('messaging.projectInstructions') : t('messaging.personalInstructions') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="scope === 'project'"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<span class="text-xs text-muted">{{ t('messaging.command') }}</span>
|
||||
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
|
||||
<span class="font-mono text-sm break-all">/connect {{ link?.token }}</span>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
@click="copy(`/connect ${link?.token}`)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
:label="scope === 'project' ? t('messaging.addBotToGroup') : t('messaging.openInTelegram')"
|
||||
icon="i-lucide-external-link"
|
||||
color="primary"
|
||||
block
|
||||
@click="openLink"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
v-if="step === 'select'"
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="open = false"
|
||||
/>
|
||||
<UButton
|
||||
v-if="step === 'select'"
|
||||
:label="provider === 'slack' ? t('messaging.continueWithSlack') : t('messaging.getLink')"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
:loading="loading"
|
||||
@click="proceed"
|
||||
/>
|
||||
<UButton
|
||||
v-else
|
||||
:label="t('common.done')"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
@click="finish"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import type { MessagingConnectLinkResult, MessagingProviderId } from 'taskview-api'
|
||||
import { useMessagingStore } from '@/stores/messaging.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import { useTaskViewMainUrl } from '@/composables/useTaskViewMainUrl'
|
||||
import $api from '@/helpers/axios'
|
||||
|
||||
const props = defineProps<{
|
||||
scope: 'user' | 'project'
|
||||
goalId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ connected: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const { copy } = useClipboard()
|
||||
const store = useMessagingStore()
|
||||
const toast = useToast()
|
||||
|
||||
const step = ref<'select' | 'link'>('select')
|
||||
const provider = ref<MessagingProviderId>('telegram')
|
||||
const link = ref<MessagingConnectLinkResult | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
step.value = 'select'
|
||||
provider.value = 'telegram'
|
||||
link.value = null
|
||||
}
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
if (provider.value === 'slack') {
|
||||
startSlackOAuth()
|
||||
return
|
||||
}
|
||||
requestLink()
|
||||
}
|
||||
|
||||
function startSlackOAuth() {
|
||||
const baseUrl = useTaskViewMainUrl()
|
||||
const token = $api.defaults.headers.common['Authorization']?.toString().replace('Bearer ', '') || ''
|
||||
// returnPath brings the user back to this in-app page after the OAuth callback,
|
||||
// instead of dumping them on the app root (which shows the login screen).
|
||||
const params = new URLSearchParams({ scope: props.scope, token, returnPath: window.location.pathname })
|
||||
if (props.scope === 'project') params.set('goalId', String(props.goalId))
|
||||
window.location.href = `${baseUrl}/module/messaging/slack/oauth/start?${params.toString()}`
|
||||
}
|
||||
|
||||
async function requestLink() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result =
|
||||
props.scope === 'project'
|
||||
? await store.connectProject(provider.value, props.goalId)
|
||||
: await store.connectPersonal(provider.value)
|
||||
if (!result) {
|
||||
toast.add({ title: t('messaging.notConfigured'), color: 'error' })
|
||||
return
|
||||
}
|
||||
link.value = result
|
||||
step.value = 'link'
|
||||
} catch {
|
||||
toast.add({ title: t('messaging.connectFailed'), color: 'error' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
if (link.value?.url) window.open(link.value.url, '_blank')
|
||||
}
|
||||
|
||||
function finish() {
|
||||
open.value = false
|
||||
emit('connected')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UIcon
|
||||
:name="providerIcon"
|
||||
class="size-6 shrink-0"
|
||||
:class="connection.isActive ? 'text-primary' : 'text-muted'"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">
|
||||
{{ connection.title || connection.targetChatId }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<UBadge
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ providerLabel }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-muted">
|
||||
{{ t('messaging.eventsCount', { count: connection.events.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isProject && canManage"
|
||||
class="flex items-center gap-2 mt-2"
|
||||
>
|
||||
<USwitch
|
||||
:model-value="connection.postContent"
|
||||
size="xs"
|
||||
@update:model-value="(v: boolean) => emit('update-post-content', v)"
|
||||
/>
|
||||
<span
|
||||
class="text-xs text-muted"
|
||||
:title="t('messaging.postContentHint')"
|
||||
>{{ t('messaging.postContent') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canManage"
|
||||
class="flex items-center gap-2 shrink-0 ml-2"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-sliders-horizontal"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
:title="t('messaging.eventsTitle')"
|
||||
@click="isEventsOpen = true"
|
||||
/>
|
||||
<USwitch
|
||||
:model-value="connection.isActive"
|
||||
size="md"
|
||||
@update:model-value="(v: boolean) => emit('toggle', v)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="md"
|
||||
:title="t('messaging.disconnect')"
|
||||
@click="isConfirmOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessagingEventsModal
|
||||
v-model:open="isEventsOpen"
|
||||
:events="connection.events"
|
||||
@save="(events) => emit('update-events', events)"
|
||||
/>
|
||||
|
||||
<UModal
|
||||
v-model:open="isConfirmOpen"
|
||||
:fullscreen="isMobile"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('messaging.disconnect') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm">
|
||||
{{ t('messaging.disconnectConfirm') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="isConfirmOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('messaging.disconnect')"
|
||||
color="error"
|
||||
variant="soft"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MessagingConnectionItem } from 'taskview-api'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import MessagingEventsModal from './MessagingEventsModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
connection: MessagingConnectionItem
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [isActive: boolean]
|
||||
delete: []
|
||||
'update-events': [events: string[]]
|
||||
'update-post-content': [postContent: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
|
||||
const isConfirmOpen = ref(false)
|
||||
const isEventsOpen = ref(false)
|
||||
|
||||
const isProject = computed(() => props.connection.ownerType === 'project')
|
||||
const providerIcon = computed(() => (props.connection.provider === 'slack' ? 'i-lucide-slack' : 'i-lucide-send'))
|
||||
const providerLabel = computed(() => t(`messaging.${props.connection.provider}`))
|
||||
|
||||
function confirmDelete() {
|
||||
isConfirmOpen.value = false
|
||||
emit('delete')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:fullscreen="isMobile"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('messaging.eventsTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
{{ t('messaging.eventsDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.key"
|
||||
>
|
||||
<p class="text-xs font-semibold text-muted uppercase mb-2">
|
||||
{{ t(`messaging.eventGroups.${group.key}`) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="event in group.events"
|
||||
:key="event"
|
||||
class="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected.includes(event)"
|
||||
@update:model-value="toggle(event)"
|
||||
/>
|
||||
<span class="text-sm">{{ eventLabel(event) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="!showAdvanced"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
icon="i-lucide-chevron-down"
|
||||
class="self-start"
|
||||
@click="showAdvanced = true"
|
||||
>
|
||||
{{ t('messaging.showMoreEvents') }}
|
||||
<span v-if="advancedSelectedCount">({{ advancedSelectedCount }})</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
icon="i-lucide-chevron-up"
|
||||
class="self-start"
|
||||
@click="showAdvanced = false"
|
||||
>
|
||||
{{ t('messaging.showLessEvents') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="open = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.save')"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{ events: string[] }>()
|
||||
const emit = defineEmits<{ save: [events: string[]] }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
|
||||
// Task events are the common ones (shown always); the rest are "noise" hidden
|
||||
// behind a "show more" toggle.
|
||||
const GROUPS = [
|
||||
{ key: 'tasks', advanced: false, events: ['task.created', 'task.assigned', 'task.statusChanged', 'task.completed', 'task.edited', 'task.addedToSprint', 'task.deleted'] },
|
||||
{ key: 'sprints', advanced: true, events: ['sprint.created', 'sprint.updated', 'sprint.started', 'sprint.reviewStarted', 'sprint.completed', 'sprint.paused', 'sprint.resumed', 'sprint.deleted'] },
|
||||
{ key: 'members', advanced: true, events: ['member.added', 'member.removed', 'member.rolesChanged'] },
|
||||
{ key: 'time', advanced: true, events: ['time.started', 'time.stopped', 'time.logged', 'time.updated', 'time.deleted'] },
|
||||
{ key: 'recurrence', advanced: true, events: ['recurrence.created', 'recurrence.updated', 'recurrence.paused', 'recurrence.resumed', 'recurrence.ended', 'recurrence.deleted', 'recurrence.skipped'] },
|
||||
]
|
||||
|
||||
const LABEL_KEY: Record<string, string> = {
|
||||
'task.created': 'taskCreated',
|
||||
'task.assigned': 'taskAssigned',
|
||||
'task.statusChanged': 'taskStatusChanged',
|
||||
'task.completed': 'taskCompleted',
|
||||
'task.edited': 'taskEdited',
|
||||
'task.addedToSprint': 'taskAddedToSprint',
|
||||
'task.deleted': 'taskDeleted',
|
||||
'sprint.created': 'sprintCreated',
|
||||
'sprint.updated': 'sprintUpdated',
|
||||
'sprint.started': 'sprintStarted',
|
||||
'sprint.reviewStarted': 'sprintReviewStarted',
|
||||
'sprint.completed': 'sprintCompleted',
|
||||
'sprint.paused': 'sprintPaused',
|
||||
'sprint.resumed': 'sprintResumed',
|
||||
'sprint.deleted': 'sprintDeleted',
|
||||
'member.added': 'memberAdded',
|
||||
'member.removed': 'memberRemoved',
|
||||
'member.rolesChanged': 'memberRolesChanged',
|
||||
'time.started': 'timeStarted',
|
||||
'time.stopped': 'timeStopped',
|
||||
'time.logged': 'timeLogged',
|
||||
'time.updated': 'timeUpdated',
|
||||
'time.deleted': 'timeDeleted',
|
||||
'recurrence.created': 'recurrenceCreated',
|
||||
'recurrence.updated': 'recurrenceUpdated',
|
||||
'recurrence.paused': 'recurrencePaused',
|
||||
'recurrence.resumed': 'recurrenceResumed',
|
||||
'recurrence.ended': 'recurrenceEnded',
|
||||
'recurrence.deleted': 'recurrenceDeleted',
|
||||
'recurrence.skipped': 'recurrenceSkipped',
|
||||
}
|
||||
|
||||
const selected = ref<string[]>([])
|
||||
const saving = ref(false)
|
||||
const showAdvanced = ref(false)
|
||||
|
||||
const advancedEvents = GROUPS.filter((g) => g.advanced).flatMap((g) => g.events)
|
||||
const advancedSelectedCount = computed(() => selected.value.filter((e) => advancedEvents.includes(e)).length)
|
||||
const visibleGroups = computed(() => (showAdvanced.value ? GROUPS : GROUPS.filter((g) => !g.advanced)))
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
selected.value = [...props.events]
|
||||
// Auto-expand if the connection already subscribes to advanced events.
|
||||
showAdvanced.value = advancedSelectedCount.value > 0
|
||||
}
|
||||
})
|
||||
|
||||
function eventLabel(event: string): string {
|
||||
return t(`messaging.eventLabels.${LABEL_KEY[event] ?? event}`)
|
||||
}
|
||||
|
||||
function toggle(event: string) {
|
||||
selected.value = selected.value.includes(event)
|
||||
? selected.value.filter((e) => e !== event)
|
||||
: [...selected.value, event]
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', [...selected.value])
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -101,6 +101,7 @@ const tools = computed<Tool[]>(() => {
|
||||
if (canViewIntegrations.value) {
|
||||
list.push({ key: 'webhooks', label: t('contextMenu.webhooks'), icon: 'i-lucide-webhook', to: { name: 'webhooks', params: { projectId: id } }, active: () => route.name === 'webhooks' })
|
||||
list.push({ key: 'integrations', label: t('contextMenu.integrations'), icon: 'i-lucide-plug', to: { name: 'integrations', params: { projectId: id } }, active: () => route.name === 'integrations' })
|
||||
list.push({ key: 'messaging', label: t('contextMenu.messaging'), icon: 'i-lucide-send', to: { name: 'messaging', params: { projectId: id } }, active: () => route.name === 'messaging' })
|
||||
}
|
||||
if (canViewTimeTracking.value) {
|
||||
list.push({ key: 'timeReports', label: t('contextMenu.timeReports'), icon: 'i-lucide-clock', to: { name: 'project-time-reports', params: { projectId: id } }, active: () => route.name === 'project-time-reports' })
|
||||
|
||||
@@ -70,10 +70,14 @@ const _useTaskDetailPanel = () => {
|
||||
}
|
||||
|
||||
function closeTask() {
|
||||
// On user route with taskId — go back (browser history), watcher will clear state
|
||||
// On user route with taskId — remove taskId from the URL; the watcher clears state.
|
||||
if (route.name === 'user' && route.params.taskId) {
|
||||
syncBaseScreen()
|
||||
router.back()
|
||||
if (window.history.state?.back != null) {
|
||||
router.back()
|
||||
} else {
|
||||
router.replace({ name: 'user', params: { ...route.params, taskId: undefined } })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+377
-14
@@ -12,8 +12,6 @@ export default {
|
||||
termsOfService: 'Nutzungsbedingungen',
|
||||
and: 'und',
|
||||
privacyPolicy: 'Datenschutzerklärung',
|
||||
|
||||
// Login by code
|
||||
email: 'E-Mail',
|
||||
emailPlaceholder: 'sie{\'@\'}beispiel.com',
|
||||
sendCode: 'Code senden',
|
||||
@@ -26,16 +24,12 @@ export default {
|
||||
resendCode: 'Code erneut senden',
|
||||
codeResent: 'Code erneut gesendet',
|
||||
newCodeSent: 'Neuer Code an {email} gesendet',
|
||||
|
||||
// Login by password
|
||||
login: 'Anmeldung',
|
||||
loginPlaceholder: 'Benutzername oder E-Mail',
|
||||
passwordLabel: 'Passwort',
|
||||
passwordPlaceholder: 'Ihr Passwort',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
signIn: 'Anmelden',
|
||||
|
||||
// Forgot password
|
||||
forgotPasswordTitle: 'Passwort zurücksetzen',
|
||||
forgotPasswordDescription: 'Geben Sie Ihre E-Mail-Adresse ein, und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.',
|
||||
sendResetLink: 'Reset-Link senden',
|
||||
@@ -44,8 +38,6 @@ export default {
|
||||
resetLinkSent: 'Wir haben einen Link zum Zurücksetzen des Passworts gesendet an',
|
||||
emailSent: 'E-Mail gesendet',
|
||||
checkInboxForReset: 'Überprüfen Sie Ihr Postfach auf den Link zum Zurücksetzen des Passworts',
|
||||
|
||||
// Reset password
|
||||
setNewPassword: 'Neues Passwort festlegen',
|
||||
setNewPasswordDescription: 'Geben Sie ein neues Passwort für Ihr Konto ein.',
|
||||
newPassword: 'Neues Passwort',
|
||||
@@ -60,8 +52,6 @@ export default {
|
||||
canNotResetPassword: 'Passwort konnte nicht zurückgesetzt werden. Bitte versuchen Sie es erneut.',
|
||||
passwordTooShort: 'Das Passwort muss mindestens 6 Zeichen lang sein',
|
||||
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
|
||||
|
||||
// Messages
|
||||
success: 'Erfolg',
|
||||
error: 'Fehler',
|
||||
loggedIn: 'Willkommen bei TaskView',
|
||||
@@ -72,14 +62,10 @@ export default {
|
||||
failedToResendCode: 'Code konnte nicht erneut gesendet werden',
|
||||
failedToSendResetLink: 'Reset-Link konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
|
||||
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche. Bitte warten Sie einige Minuten und versuchen Sie es erneut.',
|
||||
|
||||
// SSO
|
||||
ssoSignIn: 'Mit SSO anmelden',
|
||||
ssoNotFound: 'SSO nicht konfiguriert',
|
||||
ssoNotFoundDescription: 'Kein SSO-Anbieter für die Domain {domain} gefunden',
|
||||
ssoError: 'SSO-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
||||
|
||||
// Validation
|
||||
invalidEmail: 'Ungültige E-Mail-Adresse',
|
||||
loginRequired: 'Anmeldung erforderlich',
|
||||
passwordRequired: 'Passwort erforderlich',
|
||||
@@ -94,18 +80,26 @@ export default {
|
||||
namePlaceholder: 'Projektnamen eingeben',
|
||||
description: 'Beschreibung',
|
||||
descriptionPlaceholder: 'Projektbeschreibung eingeben',
|
||||
estimateUnit: 'Schätzeinheit',
|
||||
estimateUnitHours: 'Stunden',
|
||||
estimateUnitPoints: 'Story Points',
|
||||
deleteConfirm: 'Projekt löschen',
|
||||
deleteMessage: 'Sind Sie sicher, dass Sie das Projekt "{name}" löschen möchten? Der gesamte Projektverlauf wird gelöscht, einschließlich Aufgaben- und Listenverlauf. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
addPlaceholder: 'Projektnamen eingeben',
|
||||
createNew: 'Neues Projekt erstellen',
|
||||
add: 'Projekt hinzufügen',
|
||||
empty: 'Noch keine Projekte',
|
||||
inbox: 'Posteingang',
|
||||
},
|
||||
contextMenu: {
|
||||
tasks: 'Liste',
|
||||
kanban: 'Kanban',
|
||||
graph: 'Graph',
|
||||
sprints: 'Sprints',
|
||||
collaboration: 'Zusammenarbeit',
|
||||
integrations: 'Integrationen',
|
||||
webhooks: 'Webhooks',
|
||||
messaging: 'Messenger',
|
||||
timeReports: 'Zeitberichte',
|
||||
edit: 'Bearbeiten',
|
||||
moveToArchive: 'Ins Archiv verschieben',
|
||||
@@ -130,6 +124,7 @@ export default {
|
||||
common: {
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
edit: 'Bearbeiten',
|
||||
delete: 'Löschen',
|
||||
done: 'Fertig',
|
||||
yes: 'Ja',
|
||||
@@ -150,9 +145,15 @@ export default {
|
||||
editColumn: 'Spalte bearbeiten',
|
||||
columnName: 'Spaltenname',
|
||||
loadMore: 'Mehr laden',
|
||||
sprintFilter: {
|
||||
all: 'Alle Aufgaben',
|
||||
current: 'Aktueller Sprint',
|
||||
},
|
||||
},
|
||||
msg: {
|
||||
allTasks: 'Backlog',
|
||||
tools: 'Werkzeuge',
|
||||
tasks: 'Aufgaben',
|
||||
todayTasks: 'Heute',
|
||||
upcomingTasks: 'Anstehend',
|
||||
lastTasks: 'Aktuelle Aufgaben',
|
||||
@@ -166,6 +167,9 @@ export default {
|
||||
addTask: 'Aufgabe hinzufügen',
|
||||
addTaskToToday: 'Aufgabe für heute hinzufügen',
|
||||
addTaskToUpcoming: 'Anstehende Aufgabe hinzufügen',
|
||||
groupByDate: 'Nach Datum gruppieren',
|
||||
showAsList: 'Als Liste anzeigen',
|
||||
noDeadline: 'Kein Datum',
|
||||
addFirstProject: 'Fügen Sie Ihr erstes Projekt hinzu',
|
||||
today: 'Heute',
|
||||
lastAddedTasks: 'Hinzugefügt',
|
||||
@@ -190,12 +194,83 @@ export default {
|
||||
startDate: 'Startdatum',
|
||||
endDate: 'Enddatum',
|
||||
selectTime: 'Zeit auswählen',
|
||||
selectRange: 'Zeitraum auswählen',
|
||||
today: 'Heute',
|
||||
tomorrow: 'Morgen',
|
||||
yesterday: 'Gestern',
|
||||
thisWeek: 'Diese Woche',
|
||||
thisMonth: 'Diesen Monat',
|
||||
},
|
||||
recurrence: {
|
||||
title: 'Wiederholen',
|
||||
noRepeat: 'Keine Wiederholung',
|
||||
paused: 'Pausiert',
|
||||
ended: 'Beendet',
|
||||
skip: 'Termin überspringen',
|
||||
pause: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
deleteSeries: 'Serie löschen',
|
||||
selectTime: 'Uhrzeit auswählen',
|
||||
notifyOnOccurrence: 'An jeden Termin erinnern',
|
||||
preview: 'Nächste Termine',
|
||||
repeatEvery: 'Wiederholen alle',
|
||||
startDate: 'Startdatum',
|
||||
specifyTime: 'Uhrzeit angeben',
|
||||
occurrenceTime: 'Uhrzeit des Termins',
|
||||
datesCount: '{n} Termine',
|
||||
previewFooterTime: '{period} · um {time}',
|
||||
frequency: {
|
||||
daily: 'Täglich',
|
||||
weekly: 'Wöchentlich',
|
||||
monthly: 'Monatlich',
|
||||
yearly: 'Jährlich',
|
||||
},
|
||||
freqShort: {
|
||||
none: 'Keine',
|
||||
daily: 'Tag',
|
||||
weekly: 'Woche',
|
||||
monthly: 'Monat',
|
||||
yearly: 'Jahr',
|
||||
},
|
||||
units: {
|
||||
daily: 'Tage',
|
||||
weekly: 'Wochen',
|
||||
monthly: 'Monate',
|
||||
yearly: 'Jahre',
|
||||
},
|
||||
summary: {
|
||||
daily: 'Täglich',
|
||||
weekly: 'Wöchentlich',
|
||||
monthly: 'Monatlich',
|
||||
yearly: 'Jährlich',
|
||||
everyN: 'Alle {n} {unit}',
|
||||
},
|
||||
weekdays: {
|
||||
mon: 'Mo',
|
||||
tue: 'Di',
|
||||
wed: 'Mi',
|
||||
thu: 'Do',
|
||||
fri: 'Fr',
|
||||
sat: 'Sa',
|
||||
sun: 'So',
|
||||
},
|
||||
monthly: {
|
||||
dayOfMonth: 'Am {day}. Tag',
|
||||
lastDay: 'Am letzten Tag des Monats',
|
||||
},
|
||||
ends: {
|
||||
title: 'Endet',
|
||||
never: 'Nie',
|
||||
after: 'Nach N Terminen',
|
||||
onDate: 'An Datum',
|
||||
pickDate: 'Datum auswählen',
|
||||
},
|
||||
toasts: {
|
||||
saved: 'Wiederholung gespeichert',
|
||||
skipped: 'Termin übersprungen',
|
||||
deleted: 'Serie gelöscht',
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
title: 'Graph',
|
||||
description: 'Graphansicht in Kürze verfügbar',
|
||||
@@ -296,6 +371,83 @@ export default {
|
||||
sync: 'Synchronisieren',
|
||||
syncing: 'Wird synchronisiert...',
|
||||
},
|
||||
messaging: {
|
||||
title: 'Messenger-Integrationen',
|
||||
tabs: {
|
||||
personal: 'Persönlich',
|
||||
project: 'Projekt',
|
||||
},
|
||||
personalHint: 'Erhalte Direktnachrichten zu deinen Aufgaben aus all deinen Projekten. Diese Verbindung ist persönlich – sie gilt überall, wo du arbeitest, nicht nur in diesem Projekt.',
|
||||
projectHint: 'Projektereignisse in einer geteilten Gruppe oder einem Kanal posten.',
|
||||
personalEmpty: 'Noch keine persönlichen Integrationen. Verbinde Telegram, um Benachrichtigungen zu erhalten.',
|
||||
projectEmpty: 'Noch keine Projektintegrationen. Verbinde eine Gruppe, um Projektereignisse zu posten.',
|
||||
selectProject: 'Wähle ein Projekt aus, um seine Messenger-Integrationen zu verwalten.',
|
||||
noAccess: 'Du hast keine Berechtigung, die Integrationen dieses Projekts anzuzeigen.',
|
||||
connect: 'Verbinden',
|
||||
connectTitle: 'Messenger verbinden',
|
||||
connectDescription: 'Wähle einen Messenger zum Verbinden.',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
soon: 'Bald',
|
||||
getLink: 'Link abrufen',
|
||||
continueWithSlack: 'Mit Slack fortfahren',
|
||||
openInTelegram: 'In Telegram öffnen',
|
||||
addBotToGroup: 'Bot zur Gruppe hinzufügen',
|
||||
command: 'Sende dann diesen Befehl in der Gruppe:',
|
||||
personalInstructions: 'Öffne den Bot in Telegram und drücke Start, um dein Konto zu verknüpfen.',
|
||||
projectInstructions: 'Füge den Bot zu deiner Gruppe hinzu und sende dann den folgenden Befehl in dieser Gruppe, um sie zu verbinden.',
|
||||
connectedOn: 'Verbunden am {date}',
|
||||
disconnect: 'Trennen',
|
||||
disconnectConfirm: 'Möchtest du diese Integration wirklich trennen? Hier werden dann keine Benachrichtigungen mehr zugestellt.',
|
||||
notConfigured: 'Dieser Messenger ist auf dem Server nicht konfiguriert.',
|
||||
connectFailed: 'Der Verbindungslink konnte nicht erstellt werden.',
|
||||
postContent: 'Aufgabenbeschreibung an den Kanal senden',
|
||||
postContentHint: 'Aus ist sicherer: Der Kanal ist geteilt, sodass Beschreibungen die benutzerspezifischen Inhaltsberechtigungen umgehen. Wenn aus, werden nur der Aufgabentitel und der Link gepostet.',
|
||||
eventsTitle: 'Ereignisse',
|
||||
eventsDescription: 'Wähle aus, welche Ereignisse an diese Verbindung gesendet werden.',
|
||||
eventsCount: '{count} Ereignisse',
|
||||
showMoreEvents: 'Mehr Ereignisse anzeigen',
|
||||
showLessEvents: 'Weniger Ereignisse anzeigen',
|
||||
eventGroups: {
|
||||
tasks: 'Aufgaben',
|
||||
sprints: 'Sprints',
|
||||
members: 'Mitglieder',
|
||||
time: 'Zeiterfassung',
|
||||
recurrence: 'Wiederholungsregeln',
|
||||
},
|
||||
eventLabels: {
|
||||
taskCreated: 'Aufgabe erstellt',
|
||||
taskAssigned: 'Aufgabe zugewiesen',
|
||||
taskStatusChanged: 'Aufgabenstatus geändert',
|
||||
taskCompleted: 'Aufgabe abgeschlossen',
|
||||
taskEdited: 'Aufgabe bearbeitet',
|
||||
taskAddedToSprint: 'Aufgabe zum Sprint hinzugefügt',
|
||||
taskDeleted: 'Aufgabe gelöscht',
|
||||
sprintCreated: 'Sprint erstellt',
|
||||
sprintUpdated: 'Sprint bearbeitet',
|
||||
sprintStarted: 'Sprint gestartet',
|
||||
sprintReviewStarted: 'Sprint-Review gestartet',
|
||||
sprintCompleted: 'Sprint abgeschlossen',
|
||||
sprintPaused: 'Sprint pausiert',
|
||||
sprintResumed: 'Sprint fortgesetzt',
|
||||
sprintDeleted: 'Sprint gelöscht',
|
||||
memberAdded: 'Mitglied hinzugefügt',
|
||||
memberRemoved: 'Mitglied entfernt',
|
||||
memberRolesChanged: 'Mitgliederrollen geändert',
|
||||
timeStarted: 'Timer gestartet',
|
||||
timeStopped: 'Timer gestoppt',
|
||||
timeLogged: 'Zeit erfasst',
|
||||
timeUpdated: 'Zeiteintrag bearbeitet',
|
||||
timeDeleted: 'Zeiteintrag gelöscht',
|
||||
recurrenceCreated: 'Wiederholung erstellt',
|
||||
recurrenceUpdated: 'Wiederholung bearbeitet',
|
||||
recurrencePaused: 'Wiederholung pausiert',
|
||||
recurrenceResumed: 'Wiederholung fortgesetzt',
|
||||
recurrenceEnded: 'Wiederholung beendet',
|
||||
recurrenceDeleted: 'Wiederholung gelöscht',
|
||||
recurrenceSkipped: 'Termin übersprungen',
|
||||
},
|
||||
},
|
||||
webhooks: {
|
||||
title: 'Webhooks',
|
||||
selectProject: 'Wählen Sie ein Projekt, um Webhooks zu verwalten',
|
||||
@@ -348,6 +500,9 @@ export default {
|
||||
priorityLow: 'Niedrig',
|
||||
priorityMedium: 'Mittel',
|
||||
priorityHigh: 'Hoch',
|
||||
priorityLowDesc: 'Kann warten',
|
||||
priorityMediumDesc: 'Normale Wichtigkeit',
|
||||
priorityHighDesc: 'Dringend, zuerst erledigen',
|
||||
dueDate: 'Fälligkeitsdatum',
|
||||
deadline: 'Frist',
|
||||
addDueDate: 'Fälligkeitsdatum hinzufügen',
|
||||
@@ -359,6 +514,13 @@ export default {
|
||||
selectList: 'Liste auswählen',
|
||||
searchLists: 'Listen suchen...',
|
||||
noList: 'Keine Liste',
|
||||
sprintNone: 'Kein Sprint',
|
||||
sprintUpdateFailed: 'Sprint konnte nicht aktualisiert werden',
|
||||
estimatePlaceholder: 'Story Points',
|
||||
fields: {
|
||||
sprint: 'Sprint',
|
||||
estimate: 'Schätzung',
|
||||
},
|
||||
status: 'Status',
|
||||
selectStatus: 'Status auswählen',
|
||||
searchStatuses: 'Status suchen...',
|
||||
@@ -373,11 +535,17 @@ export default {
|
||||
hideCompleted: 'Abgeschlossene ausblenden',
|
||||
sortNewestFirst: 'Neueste zuerst',
|
||||
sortOldestFirst: 'Älteste zuerst',
|
||||
sortBy: 'Sortieren nach',
|
||||
sortByDate: 'Erstellungsdatum',
|
||||
sortByPriority: 'Priorität',
|
||||
sortHighestFirst: 'Höchste zuerst',
|
||||
sortLowestFirst: 'Niedrigste zuerst',
|
||||
deleteConfirm: 'Aufgabe löschen',
|
||||
deleteMessage: 'Sind Sie sicher, dass Sie "{name}" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
},
|
||||
tags: {
|
||||
createTag: 'Tag erstellen',
|
||||
createNamed: '«{name}» erstellen',
|
||||
editTag: 'Tag bearbeiten',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Tag-Name',
|
||||
@@ -433,11 +601,15 @@ export default {
|
||||
taskDetailView: 'Aufgabendetailansicht',
|
||||
taskDetailSlideover: 'Seitenleiste',
|
||||
taskDetailModal: 'Dialog',
|
||||
sidebarView: 'Seitenleisten-Ansicht',
|
||||
sidebarViewSecond: 'Kompakt',
|
||||
sidebarViewFirst: 'Klassisch',
|
||||
site: 'Website',
|
||||
documentation: 'Dokumentation',
|
||||
github: 'GitHub-Repository',
|
||||
docker: 'Docker-Images',
|
||||
accountSettings: 'Kontoeinstellungen',
|
||||
uiCustomization: 'UI-Anpassung',
|
||||
organizations: 'Organisationen',
|
||||
analytics: 'Analytik',
|
||||
timeReports: 'Zeitberichte',
|
||||
@@ -512,6 +684,7 @@ export default {
|
||||
},
|
||||
account: {
|
||||
title: 'Kontoeinstellungen',
|
||||
nav: 'Einstellungen',
|
||||
management: 'Kontoverwaltung',
|
||||
deleteAccount: 'Konto löschen',
|
||||
deleteConfirm: 'Sind Sie sicher, dass Sie das Konto und alle zugehörigen Daten löschen möchten?',
|
||||
@@ -553,6 +726,50 @@ export default {
|
||||
roleUpdated: 'Rolle aktualisiert',
|
||||
close: 'Schließen',
|
||||
slugTaken: 'Dieser Slug ist bereits vergeben',
|
||||
personal: 'Dies ist dein persönlicher Arbeitsbereich',
|
||||
personalHint: 'Wird automatisch zusammen mit deinem Konto erstellt. Er kann nicht gelöscht werden.',
|
||||
},
|
||||
uiCustomization: {
|
||||
title: 'UI-Anpassung',
|
||||
description: 'Sichtbarkeit umschalten und Elemente in den App-Bereichen neu anordnen. Zum Neuanordnen am Griff links ziehen. Alle verfügbaren Elemente werden hier angezeigt – Berechtigungsprüfungen greifen beim Rendern und können ein Element auch dann ausblenden, wenn es aktiviert ist.',
|
||||
show: 'Anzeigen',
|
||||
hide: 'Ausblenden',
|
||||
narrow: 'Schmal',
|
||||
wide: 'Breit',
|
||||
empty: 'Keine Elemente zum Anpassen',
|
||||
sections: {
|
||||
analyticsIndicators: 'Analyse – Kennzahlen',
|
||||
analyticsCharts: 'Analyse – Diagramme',
|
||||
tasks: 'Aufgaben',
|
||||
others: 'Sonstiges',
|
||||
},
|
||||
others: {
|
||||
weekStart: 'Erster Wochentag',
|
||||
weekStartHint: 'Gilt für alle Kalender-Datumsauswahlen.',
|
||||
weekStartDefault: 'Standard',
|
||||
monday: 'Montag',
|
||||
tuesday: 'Dienstag',
|
||||
wednesday: 'Mittwoch',
|
||||
thursday: 'Donnerstag',
|
||||
friday: 'Freitag',
|
||||
saturday: 'Samstag',
|
||||
sunday: 'Sonntag',
|
||||
},
|
||||
taskFields: {
|
||||
subtasks: 'Teilaufgaben',
|
||||
note: 'Beschreibung',
|
||||
status: 'Status',
|
||||
priority: 'Priorität',
|
||||
assignees: 'Zuständige',
|
||||
list: 'Liste',
|
||||
sprint: 'Sprint',
|
||||
estimate: 'Schätzung',
|
||||
tags: 'Tags',
|
||||
deadline: 'Frist',
|
||||
amount: 'Betrag',
|
||||
timeTracking: 'Zeiterfassung',
|
||||
history: 'Verlauf',
|
||||
},
|
||||
},
|
||||
sso: {
|
||||
noConfig: 'SSO ist für diese Organisation nicht konfiguriert',
|
||||
@@ -738,4 +955,150 @@ export default {
|
||||
noPermissionHint: 'Bitten Sie einen Projekt-Administrator um die Berechtigung zur Anzeige der Zeiterfassung, um auf Berichte zuzugreifen.',
|
||||
},
|
||||
},
|
||||
sprints: {
|
||||
title: 'Sprints',
|
||||
create: 'Neuer Sprint',
|
||||
editTitle: 'Sprint bearbeiten',
|
||||
empty: 'Noch keine Sprints',
|
||||
noActive: 'Kein aktiver Sprint',
|
||||
noPermission: 'Du hast keine Berechtigung, Sprints anzuzeigen',
|
||||
paused: 'Pausiert',
|
||||
deleteConfirm: 'Sprint "{name}" löschen? Das kann nicht rückgängig gemacht werden.',
|
||||
tabs: {
|
||||
backlog: 'Backlog',
|
||||
active: 'Aktiv',
|
||||
planned: 'Geplant',
|
||||
completed: 'Abgeschlossen',
|
||||
},
|
||||
status: {
|
||||
draft: 'Entwurf',
|
||||
planned: 'Geplant',
|
||||
active: 'Aktiv',
|
||||
review: 'Review',
|
||||
completed: 'Abgeschlossen',
|
||||
},
|
||||
fields: {
|
||||
name: 'Name',
|
||||
namePlaceholder: 'z. B. Sprint 14',
|
||||
goal: 'Sprint-Ziel',
|
||||
goalPlaceholder: 'Wie Erfolg für diesen Sprint aussieht',
|
||||
startDate: 'Startdatum',
|
||||
endDate: 'Enddatum',
|
||||
capacity: 'Kapazität (SP)',
|
||||
capacityPlaceholder: 'Story Points',
|
||||
estimate: 'Schätzung',
|
||||
},
|
||||
units: {
|
||||
points: 'Story Points',
|
||||
pointsShort: 'SP',
|
||||
hours: 'Stunden',
|
||||
hoursShort: 'Std',
|
||||
},
|
||||
actions: {
|
||||
activate: 'Aktivieren',
|
||||
startReview: 'Review starten',
|
||||
close: 'Sprint abschließen',
|
||||
pause: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
delete: 'Löschen',
|
||||
saveRetro: 'Retro speichern',
|
||||
plan: 'Planen',
|
||||
},
|
||||
metrics: {
|
||||
capacityUtilization: 'Kapazitätsauslastung',
|
||||
noCapacity: 'Keine Kapazität festgelegt',
|
||||
planned: 'Geplant',
|
||||
accepted: 'Angenommen',
|
||||
goal: 'Ziel',
|
||||
goalAchieved: 'Erreicht',
|
||||
goalMissed: 'Verfehlt',
|
||||
},
|
||||
taskList: {
|
||||
title: 'Aufgaben',
|
||||
empty: 'Keine Aufgaben in diesem Sprint',
|
||||
estimate: 'Schätz.',
|
||||
},
|
||||
burndown: {
|
||||
title: 'Burndown',
|
||||
empty: 'Keine Burndown-Daten verfügbar',
|
||||
ideal: 'Ideal',
|
||||
actual: 'Tatsächlich',
|
||||
},
|
||||
velocity: {
|
||||
title: 'Velocity',
|
||||
empty: 'Noch keine Velocity-Daten',
|
||||
lastN: 'Letzte {n} Sprints',
|
||||
planned: 'Geplant',
|
||||
accepted: 'Angenommen',
|
||||
},
|
||||
retro: {
|
||||
title: 'Retrospektive',
|
||||
wentWell: 'Was gut lief',
|
||||
wentWellPlaceholder: 'Dinge, die funktioniert haben',
|
||||
wentBad: 'Was schlecht lief',
|
||||
wentBadPlaceholder: 'Dinge, die verbessert werden können',
|
||||
actionItems: 'Maßnahmen',
|
||||
actionItemsPlaceholder: 'Was im nächsten Sprint geändert werden soll',
|
||||
},
|
||||
review: {
|
||||
title: 'Sprint abschließen',
|
||||
hint: 'Lege für jede Aufgabe ein Ergebnis fest, bevor du den Sprint abschließt.',
|
||||
noTasks: 'Keine Aufgaben zum Überprüfen',
|
||||
goalAchieved: 'Sprint-Ziel erreicht',
|
||||
carryTo: 'Übertragen nach',
|
||||
backlogOption: 'Backlog',
|
||||
warningTitle: 'Dies schließt den Sprint endgültig ab',
|
||||
warning: 'Nach dem Abschluss wird der Sprint als abgeschlossen markiert und zählt zur Velocity. Unfertige Aufgaben werden gemäß den Auswahlen unten übertragen oder verworfen.',
|
||||
confirmTitle: 'Diesen Sprint abschließen?',
|
||||
confirmMessage: 'Der Sprint wird als abgeschlossen markiert und seine Aufgaben werden gemäß den gewählten Ergebnissen verschoben. Fortfahren?',
|
||||
},
|
||||
outcomes: {
|
||||
accepted: 'Angenommen',
|
||||
carriedOver: 'Übertragen',
|
||||
dropped: 'Verworfen',
|
||||
},
|
||||
carryOver: {
|
||||
backlog: 'Ins Backlog verschieben',
|
||||
next: 'In nächsten Sprint verschieben',
|
||||
keep: 'Im Sprint behalten',
|
||||
},
|
||||
planning: {
|
||||
title: 'Sprint-Planung',
|
||||
backlog: 'Backlog',
|
||||
backlogEmpty: 'Keine Backlog-Aufgaben',
|
||||
inSprint: 'Im Sprint',
|
||||
sprintEmpty: 'Noch keine Aufgaben zugewiesen',
|
||||
loadError: 'Aufgaben konnten nicht geladen werden',
|
||||
retry: 'Erneut versuchen',
|
||||
},
|
||||
cadence: {
|
||||
button: 'Auto-Sprints',
|
||||
title: 'Automatische Sprints',
|
||||
description: 'Erzeuge Sprints nach einem festen Zeitplan, wie in Linear. Der aktuelle und die kommenden Sprints werden für dich erstellt – aktiviere jeden manuell, wenn du bereit bist.',
|
||||
enable: 'Auto-Sprints aktivieren',
|
||||
enableHint: 'Du kannst Sprints weiterhin manuell erstellen und abschließen.',
|
||||
length: 'Sprint-Länge',
|
||||
weeks: '{n} Woche | {n} Wochen',
|
||||
startDate: 'Erster Sprint beginnt',
|
||||
startDateHint: 'Ankerdatum – jeder Sprint zählt von hier aus vorwärts.',
|
||||
lookahead: 'Sprints im Voraus',
|
||||
lookaheadHint: 'Wie viele zukünftige Sprints über den aktuellen hinaus erstellt bleiben sollen.',
|
||||
nameTemplate: 'Namensvorlage',
|
||||
nameTemplateHint: 'Verwende {\'{n}\'}, wo die Sprint-Nummer erscheinen soll.',
|
||||
saved: 'Auto-Sprint-Einstellungen gespeichert',
|
||||
},
|
||||
notifications: {
|
||||
starting: 'Sprint beginnt bald',
|
||||
ending: 'Sprint endet bald',
|
||||
completed: 'Sprint abgeschlossen',
|
||||
},
|
||||
toasts: {
|
||||
created: 'Sprint erstellt',
|
||||
updated: 'Sprint aktualisiert',
|
||||
closed: 'Sprint abgeschlossen',
|
||||
retroSaved: 'Retrospektive gespeichert',
|
||||
assignFailed: 'Aufgabe konnte nicht aktualisiert werden',
|
||||
closeFailed: 'Sprint konnte nicht abgeschlossen werden',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export default {
|
||||
collaboration: 'Collaboration',
|
||||
integrations: 'Integrations',
|
||||
webhooks: 'Webhooks',
|
||||
messaging: 'Messengers',
|
||||
timeReports: 'Time reports',
|
||||
edit: 'Edit',
|
||||
moveToArchive: 'Move to Archive',
|
||||
@@ -384,6 +385,83 @@ export default {
|
||||
sync: 'Sync',
|
||||
syncing: 'Syncing...',
|
||||
},
|
||||
messaging: {
|
||||
title: 'Messenger integrations',
|
||||
tabs: {
|
||||
personal: 'Personal',
|
||||
project: 'Project',
|
||||
},
|
||||
personalHint: 'Receive direct messages about your tasks across all your projects. This connection is personal — it applies everywhere you work, not just this project.',
|
||||
projectHint: 'Post project events to a shared group or channel.',
|
||||
personalEmpty: 'No personal integrations yet. Connect Telegram to receive notifications.',
|
||||
projectEmpty: 'No project integrations yet. Connect a group to post project events.',
|
||||
selectProject: 'Select a project to manage its messenger integrations.',
|
||||
noAccess: 'You do not have permission to view integrations for this project.',
|
||||
connect: 'Connect',
|
||||
connectTitle: 'Connect a messenger',
|
||||
connectDescription: 'Choose a messenger to connect.',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
soon: 'Soon',
|
||||
getLink: 'Get link',
|
||||
continueWithSlack: 'Continue with Slack',
|
||||
openInTelegram: 'Open in Telegram',
|
||||
addBotToGroup: 'Add bot to group',
|
||||
command: 'Then send this command in the group:',
|
||||
personalInstructions: 'Open the bot in Telegram and press Start to link your account.',
|
||||
projectInstructions: 'Add the bot to your group, then send the command below in that group to connect it.',
|
||||
connectedOn: 'Connected on {date}',
|
||||
disconnect: 'Disconnect',
|
||||
disconnectConfirm: 'Are you sure you want to disconnect this integration? Notifications will stop being delivered here.',
|
||||
notConfigured: 'This messenger is not configured on the server.',
|
||||
connectFailed: 'Could not create the connection link.',
|
||||
postContent: 'Send task description to the channel',
|
||||
postContentHint: 'Off is safer: the channel is shared, so descriptions bypass per-user content permissions. Only the task title and link are posted when off.',
|
||||
eventsTitle: 'Events',
|
||||
eventsDescription: 'Choose which events are sent to this connection.',
|
||||
eventsCount: '{count} events',
|
||||
showMoreEvents: 'Show more events',
|
||||
showLessEvents: 'Show fewer events',
|
||||
eventGroups: {
|
||||
tasks: 'Tasks',
|
||||
sprints: 'Sprints',
|
||||
members: 'Members',
|
||||
time: 'Time tracking',
|
||||
recurrence: 'Recurring rules',
|
||||
},
|
||||
eventLabels: {
|
||||
taskCreated: 'Task created',
|
||||
taskAssigned: 'Task assigned',
|
||||
taskStatusChanged: 'Task status changed',
|
||||
taskCompleted: 'Task completed',
|
||||
taskEdited: 'Task edited',
|
||||
taskAddedToSprint: 'Task added to sprint',
|
||||
taskDeleted: 'Task deleted',
|
||||
sprintCreated: 'Sprint created',
|
||||
sprintUpdated: 'Sprint edited',
|
||||
sprintStarted: 'Sprint started',
|
||||
sprintReviewStarted: 'Sprint review started',
|
||||
sprintCompleted: 'Sprint completed',
|
||||
sprintPaused: 'Sprint paused',
|
||||
sprintResumed: 'Sprint resumed',
|
||||
sprintDeleted: 'Sprint deleted',
|
||||
memberAdded: 'Member added',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRolesChanged: 'Member roles changed',
|
||||
timeStarted: 'Timer started',
|
||||
timeStopped: 'Timer stopped',
|
||||
timeLogged: 'Time logged',
|
||||
timeUpdated: 'Time entry edited',
|
||||
timeDeleted: 'Time entry deleted',
|
||||
recurrenceCreated: 'Recurrence created',
|
||||
recurrenceUpdated: 'Recurrence edited',
|
||||
recurrencePaused: 'Recurrence paused',
|
||||
recurrenceResumed: 'Recurrence resumed',
|
||||
recurrenceEnded: 'Recurrence ended',
|
||||
recurrenceDeleted: 'Recurrence deleted',
|
||||
recurrenceSkipped: 'Occurrence skipped',
|
||||
},
|
||||
},
|
||||
webhooks: {
|
||||
title: 'Webhooks',
|
||||
selectProject: 'Select a project to manage webhooks',
|
||||
|
||||
+78
-14
@@ -12,8 +12,6 @@ export default {
|
||||
termsOfService: 'Términos del servicio',
|
||||
and: 'y',
|
||||
privacyPolicy: 'Política de privacidad',
|
||||
|
||||
// Login by code
|
||||
email: 'Correo electrónico',
|
||||
emailPlaceholder: 'tu{\'@\'}ejemplo.com',
|
||||
sendCode: 'Enviar código',
|
||||
@@ -26,16 +24,12 @@ export default {
|
||||
resendCode: 'Reenviar código',
|
||||
codeResent: 'Código reenviado',
|
||||
newCodeSent: 'Nuevo código enviado a {email}',
|
||||
|
||||
// Login by password
|
||||
login: 'Iniciar sesión',
|
||||
loginPlaceholder: 'Usuario o correo electrónico',
|
||||
passwordLabel: 'Contraseña',
|
||||
passwordPlaceholder: 'Tu contraseña',
|
||||
forgotPassword: '¿Olvidaste tu contraseña?',
|
||||
signIn: 'Iniciar sesión',
|
||||
|
||||
// Forgot password
|
||||
forgotPasswordTitle: 'Restablecer contraseña',
|
||||
forgotPasswordDescription: 'Introduce tu dirección de correo electrónico y te enviaremos un enlace para restablecer tu contraseña.',
|
||||
sendResetLink: 'Enviar enlace de restablecimiento',
|
||||
@@ -44,8 +38,6 @@ export default {
|
||||
resetLinkSent: 'Enviamos un enlace para restablecer la contraseña a',
|
||||
emailSent: 'Correo enviado',
|
||||
checkInboxForReset: 'Revisa tu bandeja de entrada para el enlace de restablecimiento de contraseña',
|
||||
|
||||
// Reset password
|
||||
setNewPassword: 'Establecer nueva contraseña',
|
||||
setNewPasswordDescription: 'Introduce una nueva contraseña para tu cuenta.',
|
||||
newPassword: 'Nueva contraseña',
|
||||
@@ -60,8 +52,6 @@ export default {
|
||||
canNotResetPassword: 'No se pudo restablecer la contraseña. Inténtalo de nuevo.',
|
||||
passwordTooShort: 'La contraseña debe tener al menos 6 caracteres',
|
||||
passwordsDoNotMatch: 'Las contraseñas no coinciden',
|
||||
|
||||
// Messages
|
||||
success: 'Éxito',
|
||||
error: 'Error',
|
||||
loggedIn: 'Te damos la bienvenida a TaskView',
|
||||
@@ -72,14 +62,10 @@ export default {
|
||||
failedToResendCode: 'Error al reenviar el código',
|
||||
failedToSendResetLink: 'Error al enviar el enlace de restablecimiento. Inténtalo de nuevo.',
|
||||
tooManyAttempts: 'Demasiados intentos fallidos. Espera unos minutos antes de volver a intentarlo.',
|
||||
|
||||
// SSO
|
||||
ssoSignIn: 'Iniciar sesión con SSO',
|
||||
ssoNotFound: 'SSO no configurado',
|
||||
ssoNotFoundDescription: 'No se encontró ningún proveedor de SSO para el dominio {domain}',
|
||||
ssoError: 'Error de autenticación SSO. Inténtalo de nuevo.',
|
||||
|
||||
// Validation
|
||||
invalidEmail: 'Dirección de correo electrónico inválida',
|
||||
loginRequired: 'El usuario es obligatorio',
|
||||
passwordRequired: 'La contraseña es obligatoria',
|
||||
@@ -113,6 +99,7 @@ export default {
|
||||
collaboration: 'Colaboración',
|
||||
integrations: 'Integraciones',
|
||||
webhooks: 'Webhooks',
|
||||
messaging: 'Mensajería',
|
||||
timeReports: 'Informes de tiempo',
|
||||
edit: 'Editar',
|
||||
moveToArchive: 'Mover al archivo',
|
||||
@@ -384,6 +371,83 @@ export default {
|
||||
sync: 'Sincronizar',
|
||||
syncing: 'Sincronizando...',
|
||||
},
|
||||
messaging: {
|
||||
title: 'Integraciones de mensajería',
|
||||
tabs: {
|
||||
personal: 'Personal',
|
||||
project: 'Proyecto',
|
||||
},
|
||||
personalHint: 'Recibe mensajes directos sobre tus tareas en todos tus proyectos. Esta conexión es personal: se aplica en todos los sitios donde trabajas, no solo en este proyecto.',
|
||||
projectHint: 'Publica los eventos del proyecto en un grupo o canal compartido.',
|
||||
personalEmpty: 'Aún no hay integraciones personales. Conecta Telegram para recibir notificaciones.',
|
||||
projectEmpty: 'Aún no hay integraciones de proyecto. Conecta un grupo para publicar los eventos del proyecto.',
|
||||
selectProject: 'Selecciona un proyecto para gestionar sus integraciones de mensajería.',
|
||||
noAccess: 'No tienes permiso para ver las integraciones de este proyecto.',
|
||||
connect: 'Conectar',
|
||||
connectTitle: 'Conectar una app de mensajería',
|
||||
connectDescription: 'Elige una app de mensajería para conectar.',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
soon: 'Pronto',
|
||||
getLink: 'Obtener enlace',
|
||||
continueWithSlack: 'Continuar con Slack',
|
||||
openInTelegram: 'Abrir en Telegram',
|
||||
addBotToGroup: 'Añadir el bot al grupo',
|
||||
command: 'Luego envía este comando en el grupo:',
|
||||
personalInstructions: 'Abre el bot en Telegram y pulsa Iniciar para vincular tu cuenta.',
|
||||
projectInstructions: 'Añade el bot a tu grupo y luego envía el comando de abajo en ese grupo para conectarlo.',
|
||||
connectedOn: 'Conectado el {date}',
|
||||
disconnect: 'Desconectar',
|
||||
disconnectConfirm: '¿Seguro que quieres desconectar esta integración? Las notificaciones dejarán de enviarse aquí.',
|
||||
notConfigured: 'Esta app de mensajería no está configurada en el servidor.',
|
||||
connectFailed: 'No se pudo crear el enlace de conexión.',
|
||||
postContent: 'Enviar la descripción de la tarea al canal',
|
||||
postContentHint: 'Desactivarlo es más seguro: el canal es compartido, así que las descripciones omiten los permisos de contenido por usuario. Cuando está desactivado, solo se publican el título y el enlace de la tarea.',
|
||||
eventsTitle: 'Eventos',
|
||||
eventsDescription: 'Elige qué eventos se envían a esta conexión.',
|
||||
eventsCount: '{count} eventos',
|
||||
showMoreEvents: 'Mostrar más eventos',
|
||||
showLessEvents: 'Mostrar menos eventos',
|
||||
eventGroups: {
|
||||
tasks: 'Tareas',
|
||||
sprints: 'Sprints',
|
||||
members: 'Miembros',
|
||||
time: 'Registro de tiempo',
|
||||
recurrence: 'Reglas recurrentes',
|
||||
},
|
||||
eventLabels: {
|
||||
taskCreated: 'Tarea creada',
|
||||
taskAssigned: 'Tarea asignada',
|
||||
taskStatusChanged: 'Estado de tarea cambiado',
|
||||
taskCompleted: 'Tarea completada',
|
||||
taskEdited: 'Tarea editada',
|
||||
taskAddedToSprint: 'Tarea añadida al sprint',
|
||||
taskDeleted: 'Tarea eliminada',
|
||||
sprintCreated: 'Sprint creado',
|
||||
sprintUpdated: 'Sprint editado',
|
||||
sprintStarted: 'Sprint iniciado',
|
||||
sprintReviewStarted: 'Revisión de sprint iniciada',
|
||||
sprintCompleted: 'Sprint completado',
|
||||
sprintPaused: 'Sprint pausado',
|
||||
sprintResumed: 'Sprint reanudado',
|
||||
sprintDeleted: 'Sprint eliminado',
|
||||
memberAdded: 'Miembro añadido',
|
||||
memberRemoved: 'Miembro eliminado',
|
||||
memberRolesChanged: 'Roles de miembro cambiados',
|
||||
timeStarted: 'Temporizador iniciado',
|
||||
timeStopped: 'Temporizador detenido',
|
||||
timeLogged: 'Tiempo registrado',
|
||||
timeUpdated: 'Registro de tiempo editado',
|
||||
timeDeleted: 'Registro de tiempo eliminado',
|
||||
recurrenceCreated: 'Recurrencia creada',
|
||||
recurrenceUpdated: 'Recurrencia editada',
|
||||
recurrencePaused: 'Recurrencia pausada',
|
||||
recurrenceResumed: 'Recurrencia reanudada',
|
||||
recurrenceEnded: 'Recurrencia finalizada',
|
||||
recurrenceDeleted: 'Recurrencia eliminada',
|
||||
recurrenceSkipped: 'Ocurrencia omitida',
|
||||
},
|
||||
},
|
||||
webhooks: {
|
||||
title: 'Webhooks',
|
||||
selectProject: 'Selecciona un proyecto para gestionar los Webhooks',
|
||||
|
||||
@@ -113,6 +113,7 @@ export default {
|
||||
collaboration: 'Совместная работа',
|
||||
integrations: 'Интеграции',
|
||||
webhooks: 'Вебхуки',
|
||||
messaging: 'Мессенджеры',
|
||||
timeReports: 'Отчёты по времени',
|
||||
edit: 'Редактировать',
|
||||
moveToArchive: 'В архив',
|
||||
@@ -384,6 +385,83 @@ export default {
|
||||
sync: 'Синхронизация',
|
||||
syncing: 'Синхронизация...',
|
||||
},
|
||||
messaging: {
|
||||
title: 'Интеграции с мессенджерами',
|
||||
tabs: {
|
||||
personal: 'Личные',
|
||||
project: 'Проект',
|
||||
},
|
||||
personalHint: 'Личные сообщения о ваших задачах во всех ваших проектах. Это подключение личное — действует везде, где вы работаете, а не только в этом проекте.',
|
||||
projectHint: 'Публикуйте события проекта в общей группе или канале.',
|
||||
personalEmpty: 'Пока нет личных интеграций. Подключите Telegram, чтобы получать уведомления.',
|
||||
projectEmpty: 'Пока нет интеграций проекта. Подключите группу для публикации событий проекта.',
|
||||
selectProject: 'Выберите проект для управления его интеграциями с мессенджерами.',
|
||||
noAccess: 'У вас нет прав для просмотра интеграций этого проекта.',
|
||||
connect: 'Подключить',
|
||||
connectTitle: 'Подключить мессенджер',
|
||||
connectDescription: 'Выберите мессенджер для подключения.',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
soon: 'Скоро',
|
||||
getLink: 'Получить ссылку',
|
||||
continueWithSlack: 'Продолжить со Slack',
|
||||
openInTelegram: 'Открыть в Telegram',
|
||||
addBotToGroup: 'Добавить бота в группу',
|
||||
command: 'Затем отправьте эту команду в группе:',
|
||||
personalInstructions: 'Откройте бота в Telegram и нажмите Start, чтобы привязать аккаунт.',
|
||||
projectInstructions: 'Добавьте бота в группу, затем отправьте команду ниже в этой группе, чтобы подключить её.',
|
||||
connectedOn: 'Подключено {date}',
|
||||
disconnect: 'Отключить',
|
||||
disconnectConfirm: 'Отключить эту интеграцию? Уведомления сюда больше приходить не будут.',
|
||||
notConfigured: 'Этот мессенджер не настроен на сервере.',
|
||||
connectFailed: 'Не удалось создать ссылку для подключения.',
|
||||
postContent: 'Отправлять описание задачи в канал',
|
||||
postContentHint: 'Выключено — безопаснее: канал общий, и описание обходит пофайловые права на контент. При выключенном шлётся только заголовок и ссылка.',
|
||||
eventsTitle: 'События',
|
||||
eventsDescription: 'Выберите, какие события отправлять в это подключение.',
|
||||
eventsCount: 'Событий: {count}',
|
||||
showMoreEvents: 'Показать больше событий',
|
||||
showLessEvents: 'Показать меньше',
|
||||
eventGroups: {
|
||||
tasks: 'Задачи',
|
||||
sprints: 'Спринты',
|
||||
members: 'Участники',
|
||||
time: 'Тайм-трекинг',
|
||||
recurrence: 'Повторения',
|
||||
},
|
||||
eventLabels: {
|
||||
taskCreated: 'Задача создана',
|
||||
taskAssigned: 'Задача назначена',
|
||||
taskStatusChanged: 'Статус задачи изменён',
|
||||
taskCompleted: 'Задача выполнена',
|
||||
taskEdited: 'Задача изменена',
|
||||
taskAddedToSprint: 'Задача добавлена в спринт',
|
||||
taskDeleted: 'Задача удалена',
|
||||
sprintCreated: 'Спринт создан',
|
||||
sprintUpdated: 'Спринт изменён',
|
||||
sprintStarted: 'Спринт начат',
|
||||
sprintReviewStarted: 'Начато ревью спринта',
|
||||
sprintCompleted: 'Спринт завершён',
|
||||
sprintPaused: 'Спринт приостановлен',
|
||||
sprintResumed: 'Спринт возобновлён',
|
||||
sprintDeleted: 'Спринт удалён',
|
||||
memberAdded: 'Участник добавлен',
|
||||
memberRemoved: 'Участник удалён',
|
||||
memberRolesChanged: 'Изменены роли участника',
|
||||
timeStarted: 'Таймер запущен',
|
||||
timeStopped: 'Таймер остановлен',
|
||||
timeLogged: 'Залогировано время',
|
||||
timeUpdated: 'Запись времени изменена',
|
||||
timeDeleted: 'Запись времени удалена',
|
||||
recurrenceCreated: 'Создано повторение',
|
||||
recurrenceUpdated: 'Повторение изменено',
|
||||
recurrencePaused: 'Повторение приостановлено',
|
||||
recurrenceResumed: 'Повторение возобновлено',
|
||||
recurrenceEnded: 'Повторение завершено',
|
||||
recurrenceDeleted: 'Повторение удалено',
|
||||
recurrenceSkipped: 'Пропущено повторение',
|
||||
},
|
||||
},
|
||||
main: 'Главная',
|
||||
mainScreen: {
|
||||
welcome: 'Добро пожаловать',
|
||||
|
||||
@@ -74,6 +74,11 @@ const router = createRouter({
|
||||
name: 'webhooks',
|
||||
component: () => import('./pages/user/webhooks.vue'),
|
||||
},
|
||||
{
|
||||
path: ':projectId/messaging',
|
||||
name: 'messaging',
|
||||
component: () => import('./pages/user/messaging.vue'),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'account',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<UDashboardPanel id="messaging">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="t('messaging.title')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<MessagingPanel />
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MessagingPanel from '@/components/features/messaging/MessagingPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { MessagingConnectionItem, MessagingConnectLinkResult, MessagingProviderId } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
|
||||
interface MessagingStoreState {
|
||||
personal: MessagingConnectionItem[]
|
||||
project: MessagingConnectionItem[]
|
||||
personalLoading: boolean
|
||||
projectLoading: boolean
|
||||
}
|
||||
|
||||
export const useMessagingStore = defineStore('messaging', {
|
||||
state: (): MessagingStoreState => ({
|
||||
personal: [],
|
||||
project: [],
|
||||
personalLoading: false,
|
||||
projectLoading: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchPersonal(): Promise<void> {
|
||||
if (this.personalLoading) return
|
||||
this.personalLoading = true
|
||||
try {
|
||||
this.personal = (await $tvApi.messaging.fetch()) || []
|
||||
} finally {
|
||||
this.personalLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async connectPersonal(provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
|
||||
return await $tvApi.messaging.connectLink(provider)
|
||||
},
|
||||
|
||||
async togglePersonal(id: number, isActive: boolean): Promise<void> {
|
||||
const result = await $tvApi.messaging.toggle({ id, isActive })
|
||||
if (!result) return
|
||||
const index = this.personal.findIndex((c) => c.id === id)
|
||||
if (index !== -1) this.personal[index] = result
|
||||
},
|
||||
|
||||
async deletePersonal(id: number): Promise<void> {
|
||||
const ok = await $tvApi.messaging.delete({ id })
|
||||
if (!ok) return
|
||||
this.personal = this.personal.filter((c) => c.id !== id)
|
||||
},
|
||||
|
||||
async updatePersonalEvents(id: number, events: string[]): Promise<void> {
|
||||
const result = await $tvApi.messaging.updateEvents({ id, events })
|
||||
if (!result) return
|
||||
const index = this.personal.findIndex((c) => c.id === id)
|
||||
if (index !== -1) this.personal[index] = result
|
||||
},
|
||||
|
||||
async fetchProject(goalId: number): Promise<void> {
|
||||
if (this.projectLoading) return
|
||||
this.projectLoading = true
|
||||
try {
|
||||
this.project = (await $tvApi.messaging.fetchProject(goalId)) || []
|
||||
} finally {
|
||||
this.projectLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async connectProject(provider: MessagingProviderId, goalId: number): Promise<MessagingConnectLinkResult | null> {
|
||||
return await $tvApi.messaging.projectConnectLink(provider, goalId)
|
||||
},
|
||||
|
||||
async toggleProject(id: number, goalId: number, isActive: boolean): Promise<void> {
|
||||
const result = await $tvApi.messaging.toggleProject({ id, goalId, isActive })
|
||||
if (!result) return
|
||||
const index = this.project.findIndex((c) => c.id === id)
|
||||
if (index !== -1) this.project[index] = result
|
||||
},
|
||||
|
||||
async deleteProject(id: number, goalId: number): Promise<void> {
|
||||
const ok = await $tvApi.messaging.deleteProject({ id, goalId })
|
||||
if (!ok) return
|
||||
this.project = this.project.filter((c) => c.id !== id)
|
||||
},
|
||||
|
||||
async updateProjectEvents(id: number, goalId: number, events: string[]): Promise<void> {
|
||||
const result = await $tvApi.messaging.updateProjectEvents({ id, goalId, events })
|
||||
if (!result) return
|
||||
const index = this.project.findIndex((c) => c.id === id)
|
||||
if (index !== -1) this.project[index] = result
|
||||
},
|
||||
|
||||
async updateProjectPostContent(id: number, goalId: number, postContent: boolean): Promise<void> {
|
||||
const result = await $tvApi.messaging.updateProjectPostContent({ id, goalId, postContent })
|
||||
if (!result) return
|
||||
const index = this.project.findIndex((c) => c.id === id)
|
||||
if (index !== -1) this.project[index] = result
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user