diff --git a/api/src/App.ts b/api/src/App.ts index eda1cb0..cc699c2 100644 --- a/api/src/App.ts +++ b/api/src/App.ts @@ -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); } diff --git a/api/src/core/all-events.ts b/api/src/core/all-events.ts index cf4f532..3d6c2a9 100644 --- a/api/src/core/all-events.ts +++ b/api/src/core/all-events.ts @@ -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() { diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 8b46d1a..da33f90 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -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" + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.56.0/0.create-messaging-tables.sql b/api/src/migrations/taskview/sql/1.56.0/0.create-messaging-tables.sql new file mode 100644 index 0000000..f972ee6 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.56.0/0.create-messaging-tables.sql @@ -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); diff --git a/api/src/migrations/taskview/sql/1.56.0/1.alter-messaging-add-events.sql b/api/src/migrations/taskview/sql/1.56.0/1.alter-messaging-add-events.sql new file mode 100644 index 0000000..b351a90 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.56.0/1.alter-messaging-add-events.sql @@ -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[]; diff --git a/api/src/migrations/taskview/sql/1.57.0/0.unique-users-to-goals.sql b/api/src/migrations/taskview/sql/1.57.0/0.unique-users-to-goals.sql new file mode 100644 index 0000000..ea4918e --- /dev/null +++ b/api/src/migrations/taskview/sql/1.57.0/0.unique-users-to-goals.sql @@ -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); diff --git a/api/src/migrations/taskview/sql/1.58.0/0.alter-messaging-add-post-content.sql b/api/src/migrations/taskview/sql/1.58.0/0.alter-messaging-add-post-content.sql new file mode 100644 index 0000000..c2d0e7f --- /dev/null +++ b/api/src/migrations/taskview/sql/1.58.0/0.alter-messaging-add-post-content.sql @@ -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; diff --git a/api/src/migrations/taskview/sql/1.59.0/0.alter-messaging-identity-add-team.sql b/api/src/migrations/taskview/sql/1.59.0/0.alter-messaging-identity-add-team.sql new file mode 100644 index 0000000..87b5a11 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.59.0/0.alter-messaging-identity-add-team.sql @@ -0,0 +1 @@ +ALTER TABLE tasks.messaging_identity_map ADD COLUMN IF NOT EXISTS external_team_id VARCHAR(255); diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index b2b3c62..fd8d287 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -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 = { '/module/integrations': IntegrationsRoutes, '/module/notifications': NotificationsRoutes, '/module/webhooks': WebhooksRoutes, + '/module/messaging': MessagingRoutes, '/module/api-tokens': ApiTokensRoutes, '/module/sessions': SessionsRoutes, '/module/organizations': OrganizationRoutes, diff --git a/api/src/tv-modules/collaboration/CollaborationRepository.ts b/api/src/tv-modules/collaboration/CollaborationRepository.ts index 06d674f..a9e7c0b 100644 --- a/api/src/tv-modules/collaboration/CollaborationRepository.ts +++ b/api/src/tv-modules/collaboration/CollaborationRepository.ts @@ -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; }) diff --git a/api/src/tv-modules/messaging/MessagingController.ts b/api/src/tv-modules/messaging/MessagingController.ts new file mode 100644 index 0000000..259096f --- /dev/null +++ b/api/src/tv-modules/messaging/MessagingController.ts @@ -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 }); + }; +} diff --git a/api/src/tv-modules/messaging/MessagingDispatcher.ts b/api/src/tv-modules/messaging/MessagingDispatcher.ts new file mode 100644 index 0000000..fd78e31 --- /dev/null +++ b/api/src/tv-modules/messaging/MessagingDispatcher.ts @@ -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 { + const boss = getJobQueue(); + await boss.createQueue(MESSAGING_DELIVER_JOB); + await boss.work(MESSAGING_DELIVER_JOB, async ([job]) => { + await this.deliverJob(job.data); + }); + } + + private async onTaskCreated(data: AppEvents['task.created']): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + 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 { + const connections = await this.repository.fetchActiveByOwner(ownerType, ownerId); + return connections.filter(c => c.events.includes(event)); + } + + private async taskAssigneeRecipients(taskId: number, goalId: number): Promise { + 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 { + 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): Promise { + 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 { + 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`); + } +} diff --git a/api/src/tv-modules/messaging/MessagingManager.ts b/api/src/tv-modules/messaging/MessagingManager.ts new file mode 100644 index 0000000..37d4e5d --- /dev/null +++ b/api/src/tv-modules/messaging/MessagingManager.ts @@ -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; + + constructor() { + this.providers = new Map([ + ['telegram', this.telegram], + ['slack', this.slack], + ]); + } + + getProvider(id: MessagingProviderId): MessagingProvider | undefined { + return this.providers.get(id); + } + + private async beginConnect(id: MessagingProviderId, ctx: ConnectContext): Promise { + 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 { + return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'user', ownerId: userId, userId })); + } + + async createProjectConnectLink(goalId: number, userId: number, provider: MessagingProviderId): Promise { + 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 { + 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): Promise { + 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 { + 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): Promise { + 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 { + const connections = await this.repository.fetchByOwner('user', userId); + return connections.map(c => this.toClient(c)); + } + + async togglePersonal(id: number, userId: number, isActive: boolean): Promise { + 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 { + return this.repository.deleteOwned({ id, ownerType: 'user', ownerId: userId }); + } + + async updatePersonalEvents(id: number, userId: number, events: string[]): Promise { + 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 { + const connections = await this.repository.fetchByOwner('project', goalId); + return connections.map(c => this.toClient(c)); + } + + async toggleProject(id: number, goalId: number, isActive: boolean): Promise { + // 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 { + return this.repository.deleteOwned({ id, ownerType: 'project', ownerId: goalId }); + } + + async updateProjectEvents(id: number, goalId: number, events: string[]): Promise { + 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 { + 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; + } +} diff --git a/api/src/tv-modules/messaging/MessagingRepository.ts b/api/src/tv-modules/messaging/MessagingRepository.ts new file mode 100644 index 0000000..0b53057 --- /dev/null +++ b/api/src/tv-modules/messaging/MessagingRepository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await callWithCatch(() => + this.db.dbDrizzle.delete(MessagingLinkTokensSchema).where(eq(MessagingLinkTokensSchema.id, id)) + ); + } + + async findUserIdByExternalId(args: MessagingIdentityLookup): Promise { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/api/src/tv-modules/messaging/MessagingRoutes.ts b/api/src/tv-modules/messaging/MessagingRoutes.ts new file mode 100644 index 0000000..5b73e08 --- /dev/null +++ b/api/src/tv-modules/messaging/MessagingRoutes.ts @@ -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 + 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) + } +} diff --git a/api/src/tv-modules/messaging/SlackInboundManager.ts b/api/src/tv-modules/messaging/SlackInboundManager.ts new file mode 100644 index 0000000..dff1f52 --- /dev/null +++ b/api/src/tv-modules/messaging/SlackInboundManager.ts @@ -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 { + 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 '); + + 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 { + if (interaction.type === 'view_submission') return this.handleAssignSubmit(interaction); + if (interaction.type === 'block_actions') return this.handleBlockAction(interaction); + } + + private async handleBlockAction(i: SlackInteractionPayload): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + 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 { + 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 ?? ''}`; + } +} diff --git a/api/src/tv-modules/messaging/config.ts b/api/src/tv-modules/messaging/config.ts new file mode 100644 index 0000000..a182c5d --- /dev/null +++ b/api/src/tv-modules/messaging/config.ts @@ -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; diff --git a/api/src/tv-modules/messaging/messages.ts b/api/src/tv-modules/messaging/messages.ts new file mode 100644 index 0000000..6df30a4 --- /dev/null +++ b/api/src/tv-modules/messaging/messages.ts @@ -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 = { + '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 }; +} diff --git a/api/src/tv-modules/messaging/middlewares/ProjectMessagingPermission.ts b/api/src/tv-modules/messaging/middlewares/ProjectMessagingPermission.ts new file mode 100644 index 0000000..bc2ea88 --- /dev/null +++ b/api/src/tv-modules/messaging/middlewares/ProjectMessagingPermission.ts @@ -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; +} diff --git a/api/src/tv-modules/messaging/middlewares/VerifySlackRequest.ts b/api/src/tv-modules/messaging/middlewares/VerifySlackRequest.ts new file mode 100644 index 0000000..b2d5151 --- /dev/null +++ b/api/src/tv-modules/messaging/middlewares/VerifySlackRequest.ts @@ -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(); +}; diff --git a/api/src/tv-modules/messaging/middlewares/VerifyTelegramWebhook.ts b/api/src/tv-modules/messaging/middlewares/VerifyTelegramWebhook.ts new file mode 100644 index 0000000..98c7a06 --- /dev/null +++ b/api/src/tv-modules/messaging/middlewares/VerifyTelegramWebhook.ts @@ -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; + } +} diff --git a/api/src/tv-modules/messaging/providers/MessagingProvider.ts b/api/src/tv-modules/messaging/providers/MessagingProvider.ts new file mode 100644 index 0000000..16855e1 --- /dev/null +++ b/api/src/tv-modules/messaging/providers/MessagingProvider.ts @@ -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; + + /** + * 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; + + /** Parse a raw inbound payload (webhook / OAuth callback) into a normalized intent, or null. */ + parseInbound(raw: InboundRaw): Promise; +} diff --git a/api/src/tv-modules/messaging/providers/slack.constants.ts b/api/src/tv-modules/messaging/providers/slack.constants.ts new file mode 100644 index 0000000..fb0c889 --- /dev/null +++ b/api/src/tv-modules/messaging/providers/slack.constants.ts @@ -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'; diff --git a/api/src/tv-modules/messaging/providers/slack.provider.ts b/api/src/tv-modules/messaging/providers/slack.provider.ts new file mode 100644 index 0000000..1918d33 --- /dev/null +++ b/api/src/tv-modules/messaging/providers/slack.provider.ts @@ -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 { + const redirectUri = process.env.SLACK_CALLBACK_URL; + const res = await axios.post( + 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 { + // 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 { + // 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 { + 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 { + 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 { + // 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): Promise { + 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 : 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'); + } +} diff --git a/api/src/tv-modules/messaging/providers/telegram.provider.ts b/api/src/tv-modules/messaging/providers/telegram.provider.ts new file mode 100644 index 0000000..27e3f27 --- /dev/null +++ b/api/src/tv-modules/messaging/providers/telegram.provider.ts @@ -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 { + 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 { + 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 ). + // Project → startgroup lets the admin add the bot to a group; the bind completes + // when they run /connect 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 { + 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 — 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(`${escapeHtml(message.body)}`); + + const label = escapeHtml(message.title); + lines.push(url ? `${label}` : label); + + if (message.footer) lines.push(escapeHtml(message.footer)); + return lines.join('\n'); + } +} diff --git a/api/src/tv-modules/messaging/types.internal.ts b/api/src/tv-modules/messaging/types.internal.ts new file mode 100644 index 0000000..70f9501 --- /dev/null +++ b/api/src/tv-modules/messaging/types.internal.ts @@ -0,0 +1,209 @@ +import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas'; +import type { MessagingEvent, MessagingOwnerType, MessagingProviderId } from './types'; + +export type MessagingConnectionForClient = Omit; + +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> }; + }; +} + +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; + }; diff --git a/api/src/tv-modules/messaging/types.ts b/api/src/tv-modules/messaging/types.ts new file mode 100644 index 0000000..80b0db9 --- /dev/null +++ b/api/src/tv-modules/messaging/types.ts @@ -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(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; diff --git a/api/src/tv-modules/messaging/utils.ts b/api/src/tv-modules/messaging/utils.ts new file mode 100644 index 0000000..2aef739 --- /dev/null +++ b/api/src/tv-modules/messaging/utils.ts @@ -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, '''); +} + +// Slack mrkdwn requires escaping these three in text (incl. link labels). +export function escapeSlackText(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/api/src/types/app.types.ts b/api/src/types/app.types.ts index 61b839a..c7c97dd 100644 --- a/api/src/types/app.types.ts +++ b/api/src/types/app.types.ts @@ -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 diff --git a/docs/3.integrations/3.messaging.md b/docs/3.integrations/3.messaging.md new file mode 100644 index 0000000..b190152 --- /dev/null +++ b/docs/3.integrations/3.messaging.md @@ -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 `) +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= # 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/setWebhook" \ + -d "url=https:///module/messaging/telegram/webhook" \ + -d "secret_token=" +``` + +Verify it took effect: + +```bash +curl "https://api.telegram.org/bot/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 ` +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 ` (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:///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:///module/messaging/slack/commands` + - Short description: `Create a TaskView task` + +3. **Interactivity & Shortcuts → On**, Request URL: + ``` + https:///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= +SLACK_CLIENT_SECRET= +SLACK_CALLBACK_URL=https:///module/messaging/slack/oauth/callback +SLACK_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 ` 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 ` in a group is ignored** +→ The bot's privacy mode is enabled. Disable it (step 2.2), or use `/connect@your_bot `. + +**"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). diff --git a/docs/3.integrations/diagrams/messaging-slack.puml b/docs/3.integrations/diagrams/messaging-slack.puml new file mode 100644 index 0000000..7445aec --- /dev/null +++ b/docs/3.integrations/diagrams/messaging-slack.puml @@ -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 (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: " (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 diff --git a/docs/3.integrations/diagrams/messaging-telegram.puml b/docs/3.integrations/diagrams/messaging-telegram.puml new file mode 100644 index 0000000..e2e5bbc --- /dev/null +++ b/docs/3.integrations/diagrams/messaging-telegram.puml @@ -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 +User -> TG: /start (private) or /connect (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 (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 diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index b4c3948..0835c3e 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -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 diff --git a/taskview-packages/taskview-api/src/api/messaging.ts b/taskview-packages/taskview-api/src/api/messaging.ts new file mode 100644 index 0000000..346acc4 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/messaging.ts @@ -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>(`${this.moduleUrl}`) + ); + } + + public async connectLink(provider: MessagingProviderId) { + return this.request( + this.$axios.get>(`${this.moduleUrl}/${provider}/connect-link`) + ); + } + + public async toggle(data: MessagingArgToggle) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/toggle`, data) + ); + } + + public async updateEvents(data: { id: number; events: string[] }) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/events`, data) + ); + } + + public async delete(data: MessagingArgDelete) { + return this.request( + this.$axios.delete>(`${this.moduleUrl}`, { data }) + ); + } + + public async fetchProject(goalId: number) { + return this.request( + this.$axios.get>(`${this.moduleUrl}/project`, { params: { goalId } }) + ); + } + + public async projectConnectLink(provider: MessagingProviderId, goalId: number) { + return this.request( + this.$axios.get>(`${this.moduleUrl}/project/${provider}/connect-link`, { params: { goalId } }) + ); + } + + public async toggleProject(data: MessagingArgProjectToggle) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/project/toggle`, data) + ); + } + + public async updateProjectEvents(data: { id: number; goalId: number; events: string[] }) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/project/events`, data) + ); + } + + public async updateProjectPostContent(data: { id: number; goalId: number; postContent: boolean }) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/project/post-content`, data) + ); + } + + public async deleteProject(data: MessagingArgProjectDelete) { + return this.request( + this.$axios.delete>(`${this.moduleUrl}/project`, { data }) + ); + } +} diff --git a/taskview-packages/taskview-api/src/api/messaging.types.ts b/taskview-packages/taskview-api/src/api/messaging.types.ts new file mode 100644 index 0000000..f35f17f --- /dev/null +++ b/taskview-packages/taskview-api/src/api/messaging.types.ts @@ -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; +}; diff --git a/taskview-packages/taskview-api/src/index.ts b/taskview-packages/taskview-api/src/index.ts index 4d46e2e..fcd0c40 100644 --- a/taskview-packages/taskview-api/src/index.ts +++ b/taskview-packages/taskview-api/src/index.ts @@ -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'; diff --git a/taskview-packages/taskview-api/src/tv.ts b/taskview-packages/taskview-api/src/tv.ts index 6e41063..b8965b1 100644 --- a/taskview-packages/taskview-api/src/tv.ts +++ b/taskview-packages/taskview-api/src/tv.ts @@ -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); diff --git a/taskview-packages/taskview-db-schemas/src/index.ts b/taskview-packages/taskview-db-schemas/src/index.ts index d92d79d..c1b70b0 100644 --- a/taskview-packages/taskview-db-schemas/src/index.ts +++ b/taskview-packages/taskview-db-schemas/src/index.ts @@ -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'; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/collaboration-users.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/collaboration-users.schema.ts index 65e81cc..8a7190e 100644 --- a/taskview-packages/taskview-db-schemas/src/schemas/collaboration-users.schema.ts +++ b/taskview-packages/taskview-db-schemas/src/schemas/collaboration-users.schema.ts @@ -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' }), diff --git a/taskview-packages/taskview-db-schemas/src/schemas/messaging.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/messaging.schema.ts new file mode 100644 index 0000000..9322b96 --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/messaging.schema.ts @@ -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; diff --git a/web/src/components/features/messaging/MessagingPanel.vue b/web/src/components/features/messaging/MessagingPanel.vue new file mode 100644 index 0000000..8daba23 --- /dev/null +++ b/web/src/components/features/messaging/MessagingPanel.vue @@ -0,0 +1,188 @@ + + + diff --git a/web/src/components/features/messaging/parts/MessagingConnectModal.vue b/web/src/components/features/messaging/parts/MessagingConnectModal.vue new file mode 100644 index 0000000..260aae5 --- /dev/null +++ b/web/src/components/features/messaging/parts/MessagingConnectModal.vue @@ -0,0 +1,196 @@ + + + diff --git a/web/src/components/features/messaging/parts/MessagingConnectionItem.vue b/web/src/components/features/messaging/parts/MessagingConnectionItem.vue new file mode 100644 index 0000000..576e43f --- /dev/null +++ b/web/src/components/features/messaging/parts/MessagingConnectionItem.vue @@ -0,0 +1,140 @@ + + + diff --git a/web/src/components/features/messaging/parts/MessagingEventsModal.vue b/web/src/components/features/messaging/parts/MessagingEventsModal.vue new file mode 100644 index 0000000..49388f8 --- /dev/null +++ b/web/src/components/features/messaging/parts/MessagingEventsModal.vue @@ -0,0 +1,171 @@ + + + diff --git a/web/src/components/sidebars/dashboard-second/SidebarTools.vue b/web/src/components/sidebars/dashboard-second/SidebarTools.vue index b4d7b9b..d9c7132 100644 --- a/web/src/components/sidebars/dashboard-second/SidebarTools.vue +++ b/web/src/components/sidebars/dashboard-second/SidebarTools.vue @@ -101,6 +101,7 @@ const tools = computed(() => { 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' }) diff --git a/web/src/composables/useTaskDetailPanel.ts b/web/src/composables/useTaskDetailPanel.ts index 81fbaf6..e9b1666 100644 --- a/web/src/composables/useTaskDetailPanel.ts +++ b/web/src/composables/useTaskDetailPanel.ts @@ -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 } diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 7a4f241..b771011 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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', + }, + }, } diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 339599b..59e6235 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index ce70a98..171d761 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 8fd50e2..b1ec191 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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: 'Добро пожаловать', diff --git a/web/src/main.ts b/web/src/main.ts index 92f89d2..c43c8c4 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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', diff --git a/web/src/pages/user/messaging.vue b/web/src/pages/user/messaging.vue new file mode 100644 index 0000000..038bbbe --- /dev/null +++ b/web/src/pages/user/messaging.vue @@ -0,0 +1,21 @@ + + + diff --git a/web/src/stores/messaging.store.ts b/web/src/stores/messaging.store.ts new file mode 100644 index 0000000..01b2b07 --- /dev/null +++ b/web/src/stores/messaging.store.ts @@ -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 { + if (this.personalLoading) return + this.personalLoading = true + try { + this.personal = (await $tvApi.messaging.fetch()) || [] + } finally { + this.personalLoading = false + } + }, + + async connectPersonal(provider: MessagingProviderId): Promise { + return await $tvApi.messaging.connectLink(provider) + }, + + async togglePersonal(id: number, isActive: boolean): Promise { + 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 { + 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 { + 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 { + 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 { + return await $tvApi.messaging.projectConnectLink(provider, goalId) + }, + + async toggleProject(id: number, goalId: number, isActive: boolean): Promise { + 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 { + 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 { + 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 { + 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 + }, + }, +})