Compare commits

..

10 Commits

Author SHA1 Message Date
Nikolai Giman 8ff7241645 chore: version 1.49.0 2026-07-06 00:09:49 +02:00
Nikolai Giman 9bdfe679bd Merge pull request #74 from Gimanh/fix/70-oidc-login-nonce
fix: oidc login
2026-07-05 23:30:11 +02:00
Nikolai Giman 4e5a330a27 fix: oidc login 2026-07-05 23:28:51 +02:00
Nikolai Giman e5860a3816 Merge pull request #73 from Gimanh/feat/messaging-integrations
feat: slack and tg integration
2026-07-05 21:37:12 +02:00
Nikolai Giman 0f4c484674 feat: slack and tg integration 2026-07-05 17:42:51 +02:00
Nikolai Giman e025c212e5 Merge pull request #69 from Gimanh/feat/inbox
feat: inbox
2026-06-29 00:55:03 +02:00
Nikolai Giman 9b08c82548 feat: inbox 2026-06-29 00:53:53 +02:00
Nikolai Giman 3d9cb5336a Merge pull request #68 from Gimanh/chore/readme.md
chore: readme
2026-06-28 16:09:30 +02:00
Nikolai Giman 8eb94d0d2b chore: readme 2026-06-28 16:09:08 +02:00
Nikolai Giman 78c910f789 Merge pull request #67 from Gimanh/feat/ui-refactor
feat: ui improvements
2026-06-28 15:07:16 +02:00
90 changed files with 5225 additions and 84 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "taskview",
"owner": {
"name": "Nikolai Giman"
},
"plugins": [
{
"name": "taskview",
"source": "./taskview-plugin",
"description": "Manage TaskView projects and tasks from Claude Code. Bundles the TaskView MCP server, skills, and slash commands."
}
]
}
+2
View File
@@ -5,6 +5,8 @@ ReleasesBuilds/*
iconcreator
*.prod.*
.DS_Store
.cursor
.claude
node_modules
__APP_BUILD__
ssl-create
+42 -3
View File
@@ -1,7 +1,7 @@
# TaskView™
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView — Kanban board" width="1440" style="max-width: 100%;">
</p>
<p align="center">
@@ -14,7 +14,7 @@
</p>
<details>
<summary><strong>View more screenshots</strong></summary>
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
<br>
@@ -60,6 +60,15 @@
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
</a>
<a href="https://github.com/Gimanh/taskview-community/releases">
<img src="https://img.shields.io/github/v/release/Gimanh/taskview-community?label=release&color=brightgreen" alt="Latest release">
</a>
<a href="https://www.npmjs.com/package/taskview-mcp">
<img src="https://img.shields.io/npm/v/taskview-mcp?label=taskview-mcp&logo=npm&color=CB3837" alt="taskview-mcp on npm">
</a>
<a href="https://www.npmjs.com/package/taskview-api">
<img src="https://img.shields.io/npm/v/taskview-api?label=taskview-api&logo=npm&color=CB3837" alt="taskview-api on npm">
</a>
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
</p>
@@ -169,7 +178,37 @@ Depending on the permissions assigned to an API token, an AI assistant can:
MCP access can be restricted by permission and by selected projects.
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
### Connecting an MCP client
The MCP server is published as [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) and runs over stdio via `npx` — no install required. You only need a TaskView API token (`tvk_...`) — generate one in your account settings (see [API tokens](https://taskview.tech/docs/features/api-tokens)). Scope the token to the minimum permissions and projects the assistant should reach.
**Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global):
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
**Claude Desktop** — add the same `mcpServers` block to `claude_desktop_config.json`.
**Other MCP clients** (Cursor, Windsurf, etc.) — use the same stdio command `npx -y taskview-mcp` with the `TASKVIEW_URL` and `TASKVIEW_TOKEN` environment variables in that client's MCP configuration.
| Variable | Required | Description |
|---|---|---|
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`, or your self-hosted instance) |
| `TASKVIEW_TOKEN` | yes | API token with the `tvk_` prefix |
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) and the [`taskview-mcp` package README](taskview-packages/taskview-mcp/README.md) for the full tool list and more options.
## Quick start
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.48.4",
"version": "1.49.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -83,4 +83,4 @@
"engines": {
"node": ">=24 <25"
}
}
}
+10 -1
View File
@@ -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);
}
+2
View File
@@ -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() {
+64
View File
@@ -640,5 +640,69 @@
"description": [
"Added tasks.recurrence_rules.has_time — explicit flag for whether a series is anchored to a wall-clock time or is date-only. Previously the code inferred 'no time' from a midnight dtstart, which silently collapsed an explicit 00:00 series into date-only. Backfill (has_time = dtstart::time <> '00:00:00') reproduces the old inference so existing series keep their behavior; new series carry the flag through from the origin task's start_time (null = date-only, set = timed, including midnight)."
]
},
"50": {
"version": "1.55.0",
"name": "Release 1.55.0",
"releaseDate": "20260628",
"scripts": [
"/1.55.0/0.alter-goals-add-is-inbox.sql",
"/1.55.0/1.backfill-inbox-goals.sql",
"/1.55.0/2.unique-inbox-per-org.sql"
],
"description": [
"Added tasks.goals.is_inbox (BOOLEAN NOT NULL DEFAULT FALSE) — flags a project as the user's personal Inbox. One Inbox per personal organization, auto-created at signup and guarded against deletion and archival in GoalsManager.",
"Backfill: for every personal organization without an Inbox, inserts one tasks.goals row (name='Inbox', is_inbox=true, owner=the org owner, organization_id=the org). The existing AFTER INSERT triggers fully provision it like any project — default kanban statuses, the owner added to collaboration, and the default editor/executor roles with their permissions. Idempotent — skips orgs that already have an Inbox.",
"Partial unique index goals_one_inbox_per_org_uidx ON tasks.goals (organization_id) WHERE is_inbox — enforces at most one Inbox per organization at the DB level, hardening the check-then-insert against concurrent races."
]
},
"51": {
"version": "1.56.0",
"name": "Messaging integrations",
"releaseDate": "20260701",
"scripts": [
"/1.56.0/0.create-messaging-tables.sql",
"/1.56.0/1.alter-messaging-add-events.sql"
],
"description": [
"Messaging integrations module (Slack / Telegram)",
"Outbound delivery connections for personal and project/org owners",
"Pending link tokens for binding + user identity map",
"Per-connection event subscription (which events to deliver)"
]
},
"52": {
"version": "1.57.0",
"name": "Unique project membership",
"releaseDate": "20260702",
"scripts": [
"/1.57.0/0.unique-users-to-goals.sql"
],
"description": [
"Deduplicate collaboration.users_to_goals rows",
"Unique index on (user_id, goal_id) — one membership per user per project"
]
},
"53": {
"version": "1.58.0",
"name": "Messaging channel post-content flag",
"releaseDate": "20260702",
"scripts": [
"/1.58.0/0.alter-messaging-add-post-content.sql"
],
"description": [
"Per-connection opt-out for posting task description to project channels"
]
},
"54": {
"version": "1.59.0",
"name": "Messaging identity workspace scoping",
"releaseDate": "20260705",
"scripts": [
"/1.59.0/0.alter-messaging-identity-add-team.sql"
],
"description": [
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
]
}
}
@@ -0,0 +1,4 @@
ALTER TABLE tasks.goals
ADD COLUMN IF NOT EXISTS is_inbox BOOLEAN NOT NULL DEFAULT FALSE;
-- Flags a project as the user's personal Inbox. One Inbox per personal organization;
-- drives auto-create at signup, the backfill below, and the delete/archive guard.
@@ -0,0 +1,17 @@
-- For every personal organization that has no Inbox yet, create one.
-- The AFTER INSERT triggers on tasks.goals fully provision the goal, exactly like
-- a normal project: default kanban statuses (kanban_add_default_columns), the owner
-- added to collaboration (add_self_to_collaboration), and the default editor/executor
-- roles with their permissions (add_roles_after_insert). The owner also has full
-- permissions implicitly.
-- Idempotent: orgs that already have an Inbox are skipped.
INSERT INTO tasks.goals (name, owner, organization_id, is_inbox)
SELECT 'Inbox', o.owner_id, o.id, TRUE
FROM tv_auth.organizations o
WHERE o.is_personal = 1
AND NOT EXISTS (
SELECT 1
FROM tasks.goals g
WHERE g.organization_id = o.id
AND g.is_inbox = TRUE
);
@@ -0,0 +1,7 @@
-- Hard guarantee of at most one Inbox per organization. Defends the
-- check-then-insert in GoalsRepository.createInboxGoal against concurrent
-- signups/calls that could both pass the findInboxGoal precheck and insert.
-- Safe to create here: it runs after the idempotent backfill, so no duplicates exist.
CREATE UNIQUE INDEX IF NOT EXISTS goals_one_inbox_per_org_uidx
ON tasks.goals (organization_id)
WHERE is_inbox;
@@ -0,0 +1,47 @@
-- Messaging integrations (Slack / Telegram): outbound delivery targets, pending
-- binding tokens, and the map between a TaskView user and their external account.
-- See promo/integrations.md for the full design.
CREATE TABLE IF NOT EXISTS tasks.messaging_connections (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
provider VARCHAR(20) NOT NULL,
owner_type VARCHAR(20) NOT NULL,
owner_id INTEGER NOT NULL,
target_chat_id VARCHAR(255) NOT NULL,
title VARCHAR(255),
external_team_id VARCHAR(255),
access_token_encrypted VARCHAR,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- One delivery target per (provider, owner, chat) — re-connecting the same chat updates instead of duplicating.
CREATE UNIQUE INDEX IF NOT EXISTS messaging_connection_unique
ON tasks.messaging_connections (provider, owner_type, owner_id, target_chat_id);
-- Pending binding intent, redeemed from the messenger. Owner is polymorphic
-- (user / project / organization); permission to bind is checked when the token is minted.
CREATE TABLE IF NOT EXISTS tasks.messaging_link_tokens (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
token VARCHAR(128) NOT NULL UNIQUE,
provider VARCHAR(20) NOT NULL,
owner_type VARCHAR(20) NOT NULL,
owner_id INTEGER NOT NULL,
created_by INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tasks.messaging_identity_map (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
provider VARCHAR(20) NOT NULL,
external_user_id VARCHAR(255),
linked_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- One identity per provider per user.
CREATE UNIQUE INDEX IF NOT EXISTS messaging_identity_unique
ON tasks.messaging_identity_map (user_id, provider);
@@ -0,0 +1,5 @@
-- Per-connection event subscription: which events this messaging connection delivers.
-- Default covers the common task events; users opt into sprint events in the UI.
ALTER TABLE tasks.messaging_connections
ADD COLUMN IF NOT EXISTS events VARCHAR[] NOT NULL
DEFAULT ARRAY['task.created','task.assigned','task.statusChanged','task.completed']::VARCHAR[];
@@ -0,0 +1,15 @@
-- A user is either a member of a project or not — there is no meaning to two
-- membership rows for the same (user_id, goal_id). Roles live in a separate table
-- (collaboration.users_to_roles), so multi-role membership does not need duplicate rows.
-- Dedupe any existing duplicates (the table has no PK, so key off ctid), then enforce
-- uniqueness. Insert paths use ON CONFLICT DO NOTHING so re-adding a member is idempotent.
DELETE FROM collaboration.users_to_goals
WHERE ctid NOT IN (
SELECT MIN(ctid)
FROM collaboration.users_to_goals
GROUP BY user_id, goal_id
);
CREATE UNIQUE INDEX IF NOT EXISTS users_to_goals_user_goal_uidx
ON collaboration.users_to_goals (user_id, goal_id);
@@ -0,0 +1,5 @@
-- Project channels: opt-out flag for including the RBAC-gated task description in the
-- channel message. Default TRUE (channel is a deliberate broadcast). Personal DMs are
-- unaffected — they always gate the description per recipient (COMPONENT_CAN_WATCH_CONTENT).
ALTER TABLE tasks.messaging_connections
ADD COLUMN IF NOT EXISTS post_content BOOLEAN NOT NULL DEFAULT TRUE;
@@ -0,0 +1 @@
ALTER TABLE tasks.messaging_identity_map ADD COLUMN IF NOT EXISTS external_team_id VARCHAR(255);
+2
View File
@@ -6,6 +6,7 @@ import GraphRoutes from '../tv-modules/graph/GraphRoutes';
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
import MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
@@ -39,6 +40,7 @@ const routes: Record<string, RoutableConstructor> = {
'/module/integrations': IntegrationsRoutes,
'/module/notifications': NotificationsRoutes,
'/module/webhooks': WebhooksRoutes,
'/module/messaging': MessagingRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
+4 -1
View File
@@ -20,6 +20,7 @@ import RuEmailTemplate from './mail/confirm-email-ru';
import LoginCodeEmailTemplate from './mail/login-code-en';
import type { ExternalAuthUser } from './strategies/external-auth.types';
import { OrganizationRepository } from '../organizations/OrganizationRepository';
import { GoalsRepository } from '../goals/GoalsRepository';
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
@@ -30,12 +31,14 @@ export default class AuthController {
private readonly refreshTokenCookieName: string = 'taskview-refresh';
private readonly orgRepository: OrganizationRepository = new OrganizationRepository();
private readonly goalsRepository: GoalsRepository = new GoalsRepository();
private async createPersonalWorkspace(userId: number, email: string, login: string) {
const slug = `org-${crypto.randomUUID().slice(0, 8)}`
const org = await this.orgRepository.create({ name: `${login}'s workspace`, slug }, userId, true)
if (org) {
await this.orgRepository.addMember(org.id, email, 'owner')
await this.goalsRepository.createInboxGoal({ ownerId: userId, organizationId: org.id })
}
}
@@ -310,7 +313,7 @@ export default class AuthController {
loginByCode = async (req: Request, res: Response) => {
const schema = z.object({
email: z.string().trim().email().toLowerCase(),
code: z.string().trim().regex(/^\d{6}$/, '6-digit code'),
code: z.string().trim().min(6).max(64),
});
const data = schema.safeParse(req.body);
@@ -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;
})
+20
View File
@@ -63,8 +63,16 @@ export default class GoalsManager {
return new GoalItemForClient(goal, (await this.getPermissionsForGoal(goal.id)).getAllPermissions());
}
private async isInboxGoal(goalId: number): Promise<boolean> {
const goal = await this.goalsRepository.findGoalById(goalId);
return !!goal && goal.isInbox;
}
/** @deprecated use deleteGoalNew instead */
async deleteGoal(goalId: number): Promise<boolean> {
if (await this.isInboxGoal(goalId)) {
return false;
}
return await this.goalsRepository.deleteGoal(goalId);
}
@@ -88,6 +96,9 @@ export default class GoalsManager {
}
async updateArchive(goalId: number, archive: GoalItemInDb['archive']) {
if (archive === 1 && (await this.isInboxGoal(goalId))) {
return false;
}
return await this.goalsRepository.updateArchive(goalId, archive);
}
@@ -134,6 +145,12 @@ export default class GoalsManager {
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsItemForClientWithPermissions | false> {
// The Inbox must never be archived. Archiving flows through this endpoint
// (PATCH /module/goals), not the unrouted updateArchive, so the guard lives here.
if (goalData.archive === 1 && (await this.isInboxGoal(goalData.id))) {
return false;
}
const goal = await this.goalsRepository.updateGoalNew(goalData);
if (!goal) {
@@ -153,6 +170,9 @@ export default class GoalsManager {
}
async deleteGoalNew(goalData: GoalsArgDelete) {
if (await this.isInboxGoal(goalData.goalId)) {
return false;
}
return await this.goalsRepository.deleteGoalNew(goalData);
}
+51 -3
View File
@@ -12,7 +12,7 @@ import type { AddGoalToDbArg, GoalItemInDb, GoalItemsInDb, UpdateGoalDbArg } fro
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import { callWithCatch } from '../../utils/helpers';
import type { GoalsArgAdd, GoalsArgDelete, GoalsArgUpdate } from './types';
import type { GoalsArgAdd, GoalsArgCreateInbox, GoalsArgDelete, GoalsArgUpdate } from './types';
export class GoalsRepository {
private readonly db: Database;
@@ -157,6 +157,7 @@ export class GoalsRepository {
backlogVersion: GoalsSchema.backlogVersion,
organizationId: GoalsSchema.organizationId,
estimateUnit: GoalsSchema.estimateUnit,
isInbox: GoalsSchema.isInbox,
})
.from(GoalsSchema)
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
@@ -232,9 +233,56 @@ export class GoalsRepository {
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
async findInboxGoal(organizationId: number): Promise<GoalsSchemaTypeForSelect | false> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(goalData).where(eq(GoalsSchema.id, goalData.id)).returning()
this.db.dbDrizzle
.select()
.from(GoalsSchema)
.where(and(eq(GoalsSchema.organizationId, organizationId), eq(GoalsSchema.isInbox, true)))
);
if (!result || result.length === 0) return false;
return result[0];
}
async createInboxGoal(args: GoalsArgCreateInbox): Promise<GoalsSchemaTypeForSelect | false> {
const existing = await this.findInboxGoal(args.organizationId);
if (existing) {
return existing;
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(GoalsSchema)
.values({
name: 'Inbox',
owner: args.ownerId,
organizationId: args.organizationId,
isInbox: true,
})
.returning()
);
if (!result) {
return false;
}
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
const updates: Partial<typeof GoalsSchema.$inferInsert> = {};
if (goalData.name !== undefined) updates.name = goalData.name;
if (goalData.description !== undefined) updates.description = goalData.description;
if (goalData.color !== undefined) updates.color = goalData.color;
if (goalData.estimateUnit !== undefined) updates.estimateUnit = goalData.estimateUnit;
if (goalData.archive !== undefined) updates.archive = goalData.archive;
if (Object.keys(updates).length === 0) {
return this.findGoalById(goalData.id);
}
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(updates).where(eq(GoalsSchema.id, goalData.id)).returning()
);
if (!result) {
return false;
+6
View File
@@ -17,6 +17,7 @@ export const GoalsArkTypeUpdate = type({
'description?': 'string | null',
'color?': 'string | null',
"estimateUnit?": "'hours' | 'points'",
'archive?': '0 | 1',
});
export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer;
@@ -34,3 +35,8 @@ export const GoalsArkTypeFetch = type({
export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer;
export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient };
export type GoalsArgCreateInbox = {
ownerId: number;
organizationId: number;
};
@@ -0,0 +1,241 @@
import type { Request, Response } from 'express';
import { ArkErrors } from 'arktype';
import AuthController from '../auth/AuthController';
import { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { $logger } from '../../modules/logget';
import { MessagingManager } from './MessagingManager';
import { SlackInboundManager } from './SlackInboundManager';
import { SLACK_OAUTH_NONCE_COOKIE } from './config';
import {
MessagingArkTypeConnectLink,
MessagingArkTypeById,
MessagingArkTypeToggle,
MessagingArkTypeUpdateEvents,
MessagingArkTypeProviderParam,
MessagingArkTypeProjectToggle,
MessagingArkTypeProjectDelete,
MessagingArkTypeProjectPostContent,
} from './types';
import { getAuthorizedGoalId } from './middlewares/ProjectMessagingPermission';
export class MessagingController {
private readonly manager = new MessagingManager();
private readonly slackInbound = new SlackInboundManager();
// Slash command (/task). Signature already verified by VerifySlackRequest.
slackCommands = async (req: Request, res: Response) => {
try {
const reply = await this.slackInbound.handleSlashCommand(req.body ?? {});
return res.json(reply);
} catch (err) {
$logger.error(err, '[Messaging/Slack] slash command failed');
return res.json({ response_type: 'ephemeral', text: 'Something went wrong handling that command.' });
}
};
// Button clicks and modal submissions. Payload is a JSON string in the `payload` field.
slackInteractivity = async (req: Request, res: Response) => {
try {
const raw = (req.body as { payload?: unknown })?.payload;
const interaction = typeof raw === 'string' ? JSON.parse(raw) : raw;
const result = await this.slackInbound.handleInteraction(interaction ?? {});
return res.json(result ?? {});
} catch (err) {
$logger.error(err, '[Messaging/Slack] interactivity failed');
return res.status(200).end();
}
};
fetch = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.fetchPersonalConnections(userId);
return res.tvJson(result);
};
connectLink = async (req: Request, res: Response) => {
const data = MessagingArkTypeConnectLink({ ...req.params, ...req.query });
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.createPersonalConnectLink(userId, data.provider);
if (!result) return res.status(503).end();
return res.tvJson(result);
};
toggle = async (req: Request, res: Response) => {
const data = MessagingArkTypeToggle(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.togglePersonal(data.id, userId, data.isActive);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
delete = async (req: Request, res: Response) => {
const data = MessagingArkTypeById(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.deletePersonal(data.id, userId);
return res.tvJson(result);
};
updateEvents = async (req: Request, res: Response) => {
const data = MessagingArkTypeUpdateEvents(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.updatePersonalEvents(data.id, userId, data.events);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
fetchProject = async (_req: Request, res: Response) => {
const result = await this.manager.fetchProjectConnections(getAuthorizedGoalId(res));
return res.tvJson(result);
};
projectConnectLink = async (req: Request, res: Response) => {
const data = MessagingArkTypeProviderParam(req.params);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.createProjectConnectLink(getAuthorizedGoalId(res), userId, data.provider);
if (!result) return res.status(503).end();
return res.tvJson(result);
};
toggleProject = async (req: Request, res: Response) => {
const data = MessagingArkTypeProjectToggle(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.toggleProject(data.id, getAuthorizedGoalId(res), data.isActive);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
deleteProject = async (req: Request, res: Response) => {
const data = MessagingArkTypeProjectDelete(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.deleteProject(data.id, getAuthorizedGoalId(res));
return res.tvJson(result);
};
updateProjectEvents = async (req: Request, res: Response) => {
const data = MessagingArkTypeUpdateEvents(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.updateProjectEvents(data.id, getAuthorizedGoalId(res), data.events);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
updateProjectPostContent = async (req: Request, res: Response) => {
const data = MessagingArkTypeProjectPostContent(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.updateProjectPostContent(data.id, getAuthorizedGoalId(res), data.postContent);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
// Slack uses OAuth (browser redirect), so the user's JWT is passed as ?token and
// validated manually — a full-page redirect can't carry the API Authorization header.
slackOAuthStart = async (req: Request, res: Response) => {
try {
const token = req.query.token as string;
if (!token) return res.status(401).send('token is required');
const userPayload = await AuthController.validateTokens(token);
const userId = userPayload?.userData?.id;
if (!userId) return res.status(401).send('Invalid token');
if (!this.manager.getProvider('slack')?.isConfigured()) {
return res.status(503).send('Slack is not configured on the server (set SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_CALLBACK_URL)');
}
const scope: 'user' | 'project' = req.query.scope === 'project' ? 'project' : 'user';
let ownerId = userId;
if (scope === 'project') {
const goalId = Number(req.query.goalId);
if (!goalId || Number.isNaN(goalId)) return res.status(400).send('goalId is required');
const checker = await new AppUser(userPayload).permissionsFetcher.getCheckerForGoal(goalId);
if (!checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) return res.status(403).end();
ownerId = goalId;
}
const returnPath = this.safeReturnPath(req.query.returnPath);
const start = await this.manager.startOAuthConnect('slack', { ownerType: scope, ownerId, userId, returnPath });
if (!start) return res.status(503).send('Slack is not configured on the server');
// The provider hands back the anti-CSRF nonce cookie to set on this browser.
if (start.kind === 'oauth') {
res.cookie(start.setCookie.name, start.setCookie.value, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: start.setCookie.maxAgeMs,
});
}
return res.redirect(start.url);
} catch (err) {
$logger.error(err, '[Messaging/Slack] Failed to start OAuth');
return res.status(500).send('Failed to start Slack OAuth');
}
};
slackOAuthCallback = async (req: Request, res: Response) => {
const code = req.query.code as string;
const state = req.query.state as string;
const nonce = req.cookies?.[SLACK_OAUTH_NONCE_COOKIE] as string | undefined;
res.clearCookie(SLACK_OAUTH_NONCE_COOKIE);
if (!code || !state) return res.redirect(`${process.env.APP_URL}?messaging=error`);
try {
const result = await this.manager.handleInbound('slack', { source: 'oauth-callback', payload: { code, state }, cookie: nonce });
return res.redirect(`${process.env.APP_URL}${this.safeReturnPath(result?.redirect)}?messaging=connected`);
} catch (err) {
$logger.error({ error: err instanceof Error ? err.message : String(err), hasNonce: !!nonce }, '[Messaging/Slack] OAuth callback failed');
return res.redirect(`${process.env.APP_URL}?messaging=error`);
}
};
private safeReturnPath(value: unknown): string {
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')
? value
: '';
}
telegramWebhook = async (req: Request, res: Response) => {
res.status(200).end();
await this.manager.handleInbound('telegram', { source: 'webhook', payload: req.body });
};
}
@@ -0,0 +1,371 @@
import { eq } from 'drizzle-orm';
import { SprintsSchema, TasksSchema } from 'taskview-db-schemas';
import { eventBus, type AppEvents } from '../../core/EventBus';
import { getJobQueue } from '../../core/JobQueue';
import { Database } from '../../modules/db';
import { decrypt } from '../../utils/crypto';
import { $logger } from '../../modules/logget';
import { GoalPermissionsRepository } from '../../core/GoalPermissionsRepository';
import { GoalPermissionsChecker } from '../../core/GoalPermissionsChecker';
import { GoalPermissions } from '../../types/auth.types';
import { MessagingRepository } from './MessagingRepository';
import { MessagingManager } from './MessagingManager';
import { buildMessagingMessage } from './messages';
import { buildTaskDeepLink } from './utils';
import type { Dispatcher } from '../../core/Dispatcher';
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
import type { MessagingDeliverJobData, MessagingEvent, MessagingProviderId } from './types';
import type { MessagingDispatchArgs, MessagingRecipient, MessagingTaskContext } from './types.internal';
const MESSAGING_DELIVER_JOB = 'messaging-deliver';
const MAX_ATTEMPTS = 3;
export class MessagingDispatcher implements Dispatcher {
private readonly repository = new MessagingRepository();
private readonly manager = new MessagingManager();
private readonly permissionsRepo = new GoalPermissionsRepository();
register(): void {
eventBus.on('task.created', (data) => this.onTaskCreated(data));
eventBus.on('task.assigneesChanged', (data) => this.onTaskAssigned(data));
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
eventBus.on('task.assignedToSprint', (data) => this.onTaskAddedToSprint(data));
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
eventBus.on('sprint.created', (data) => this.onSprintObj('sprint.created', data));
eventBus.on('sprint.updated', (data) => this.onSprintObj('sprint.updated', data));
eventBus.on('sprint.activated', (data) => this.onSprint('sprint.started', data));
eventBus.on('sprint.reviewStarted', (data) => this.onSprint('sprint.reviewStarted', data));
eventBus.on('sprint.completed', (data) => this.onSprint('sprint.completed', data));
eventBus.on('sprint.paused', (data) => this.onSprint('sprint.paused', data));
eventBus.on('sprint.resumed', (data) => this.onSprint('sprint.resumed', data));
eventBus.on('sprint.deleted', (data) => this.onSprint('sprint.deleted', data));
eventBus.on('collaboration.userAdded', (data) => this.onMember('member.added', data.goalId, data.email, data.initiatorId));
eventBus.on('collaboration.userRemoved', (data) => this.onMemberByCollab('member.removed', data.goalId, data.collaborationUserId, data.initiatorId));
eventBus.on('collaboration.rolesChanged', (data) => this.onMemberByCollab('member.rolesChanged', data.goalId, data.collaborationUserId, data.initiatorId));
eventBus.on('time-entry.started', (data) => this.onTimeEntry('time.started', data.goalId, data.taskId, data.userId));
eventBus.on('time-entry.stopped', (data) => this.onTimeEntry('time.stopped', data.goalId, data.taskId, data.userId));
eventBus.on('time-entry.created', (data) => this.onTimeEntry('time.logged', data.entry.goalId, data.entry.taskId, data.initiatorId));
eventBus.on('time-entry.updated', (data) => this.onTimeEntry('time.updated', data.entry.goalId, data.entry.taskId, data.initiatorId));
eventBus.on('time-entry.deleted', (data) => this.onTimeEntry('time.deleted', data.goalId, data.taskId, data.initiatorId));
eventBus.on('recurrence.created', (data) => this.onRecurrence('recurrence.created', data.rule.goalId, data.initiatorId));
eventBus.on('recurrence.updated', (data) => this.onRecurrence('recurrence.updated', data.rule.goalId, data.initiatorId));
eventBus.on('recurrence.paused', (data) => this.onRecurrence('recurrence.paused', data.goalId, data.initiatorId));
eventBus.on('recurrence.resumed', (data) => this.onRecurrence('recurrence.resumed', data.goalId, data.initiatorId));
eventBus.on('recurrence.ended', (data) => this.onRecurrence('recurrence.ended', data.goalId, data.initiatorId));
eventBus.on('recurrence.deleted', (data) => this.onRecurrence('recurrence.deleted', data.goalId, data.initiatorId));
eventBus.on('recurrence.instanceSkipped', (data) => this.onRecurrence('recurrence.skipped', data.goalId, data.initiatorId));
}
async registerWorkers(): Promise<void> {
const boss = getJobQueue();
await boss.createQueue(MESSAGING_DELIVER_JOB);
await boss.work<MessagingDeliverJobData>(MESSAGING_DELIVER_JOB, async ([job]) => {
await this.deliverJob(job.data);
});
}
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
// A brand-new task usually has no assignees yet, so target project members.
const recipients = await this.repository.fetchProjectMemberRecipients(data.task.goalId);
await this.dispatch({
event: 'task.created',
goalId: data.task.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(data.task)
});
}
private async onTaskAssigned(data: AppEvents['task.assigneesChanged']): Promise<void> {
if (data.userIds.length === 0) return;
const task = await this.fetchTask(data.taskId);
if (!task) return;
// Keep only collab ids still assigned AND still project members, then resolve to auth recipients.
const currentAssignees = new Set(await this.repository.fetchCurrentAssigneeCollabIds(task.id));
const stillAssigned = data.userIds.filter(id => currentAssignees.has(id));
const memberCollabIds = await this.repository.filterCollabIdsInGoal(stillAssigned, task.goalId);
if (memberCollabIds.length === 0) return;
const recipients = await this.repository.resolveCollabIdsToRecipients(memberCollabIds);
await this.dispatch({
event: 'task.assigned',
goalId: task.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(task)
});
}
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
const changes = data.changes ?? {};
let event: MessagingEvent;
let titleOverride: string | undefined;
if ('complete' in changes && data.task.complete === true) event = 'task.completed';
else if ('complete' in changes && data.task.complete === false) {
// Reopen: gated by the same task.completed subscription, but shown as "reopened".
event = 'task.completed';
titleOverride = '[Task reopened]';
} else if ('statusId' in changes) event = 'task.statusChanged';
else event = 'task.edited';
const recipients = await this.taskAssigneeRecipients(data.task.id, data.task.goalId);
await this.dispatch({
event,
goalId: data.task.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(data.task),
titleOverride
});
}
private async onTaskAddedToSprint(data: AppEvents['task.assignedToSprint']): Promise<void> {
if (!data.sprintId) return;
const task = await this.fetchTask(data.taskId);
if (!task) return;
const recipients = await this.taskAssigneeRecipients(data.taskId, data.goalId);
await this.dispatch({
event: 'task.addedToSprint',
goalId: data.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(task)
});
}
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
await this.dispatch({
event: 'task.deleted',
goalId: data.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
body: `#${data.taskId}`
});
}
private async onSprintObj(event: MessagingEvent, data: { sprint: { goalId: number; name: string }; initiatorId: number }): Promise<void> {
const recipients = await this.repository.fetchProjectMemberRecipients(data.sprint.goalId);
await this.dispatch({
event,
goalId: data.sprint.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
body: data.sprint.name
});
}
private async onSprint(event: MessagingEvent, data: { sprintId: number; goalId: number; initiatorId: number | null }): Promise<void> {
const name = await this.fetchSprintName(data.sprintId);
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
await this.dispatch({
event,
goalId: data.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
body: name
});
}
private async onMember(event: MessagingEvent, goalId: number, body: string, initiatorId: number): Promise<void> {
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
await this.dispatch({
event,
goalId,
personalRecipients: recipients,
initiatorId,
body
});
}
private async onMemberByCollab(event: MessagingEvent, goalId: number, collabId: number, initiatorId: number): Promise<void> {
const email = await this.repository.fetchCollabEmail(collabId);
await this.onMember(event, goalId, email ?? '', initiatorId);
}
private async onTimeEntry(event: MessagingEvent, goalId: number, taskId: number, initiatorId: number | null): Promise<void> {
const task = await this.fetchTask(taskId);
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
// Time entries reference a task → gate the task description like task events.
await this.dispatch({
event,
goalId,
personalRecipients: recipients,
initiatorId,
task: task ? this.taskCtx(task) : undefined,
body: task ? undefined : `#${taskId}`
});
}
private async onRecurrence(event: MessagingEvent, goalId: number, initiatorId: number): Promise<void> {
// Recurring-rule template content is not shown (avoids leaking gated task content); title only.
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
await this.dispatch({ event, goalId, personalRecipients: recipients, initiatorId, body: '' });
}
private async dispatch(args: MessagingDispatchArgs): Promise<void> {
// No initiator exclusion: messaging is an explicit opt-in feed — if you
// subscribed to an event you receive it, even for your own actions.
const url = args.task ? await this.buildTaskUrl(args.goalId, args.task.id, args.task.goalListId) : undefined;
const projectName = await this.repository.fetchGoalName(args.goalId);
// Assignee emails are RBAC-gated content. Compute once, then include per recipient
// only when they may see assignees (TASKS_CAN_WATCH_ASSIGNED_USERS) — same treatment
// as the description, which is gated by COMPONENT_CAN_WATCH_CONTENT.
const footerText = args.task ? await this.assigneesFooter(args.task.id) : undefined;
let personalCount = 0;
for (const r of args.personalRecipients) {
const checker = args.task ? await this.checkerForRecipient(args.goalId, r) : null;
const body = this.bodyForRecipient(args, checker);
const footer = footerText && checker?.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS) ? footerText : undefined;
const message = buildMessagingMessage({
event: args.event,
audience: 'personal',
body,
url,
taskId: args.task?.id,
projectName,
footer,
completed: args.task?.complete,
titleOverride: args.titleOverride
});
const connections = await this.subscribed('user', r.userId, args.event);
personalCount += connections.length;
await this.enqueueAll(connections, message);
}
// Project channel: a shared channel's membership doesn't map to TaskView content
// permission. By default the description AND assignees ARE posted (a channel is a
// deliberate broadcast); a connection can opt out via post_content — then task events
// get title + link only (no description, no assignee emails). Non-task bodies
// (sprint name, …) are not RBAC-gated content.
const projectConnections = await this.subscribed('project', args.goalId, args.event);
for (const connection of projectConnections) {
const projectBody = args.task
? (connection.postContent ? (args.task.description ?? `#${args.task.id}`) : '')
: (args.body ?? '');
const projectFooter = connection.postContent ? footerText : undefined;
// A content-hidden channel gets title + link only — no description, no assignee footer,
// and no action buttons (their modals would expose members / task state).
const projectMessage = buildMessagingMessage({
event: args.event,
audience: 'project',
body: projectBody,
url,
taskId: args.task?.id,
projectName,
footer: projectFooter,
completed: args.task?.complete,
titleOverride: args.titleOverride,
actions: connection.postContent
});
await this.enqueueAll([connection], projectMessage);
}
$logger.info(
{ event: args.event, goalId: args.goalId, members: args.personalRecipients.length, personalConns: personalCount, projectConns: projectConnections.length },
'[Messaging] dispatch',
);
}
private bodyForRecipient(args: MessagingDispatchArgs, checker: GoalPermissionsChecker | null): string {
if (!args.task) return args.body ?? '';
const canWatch = checker?.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) ?? false;
return canWatch ? (args.task.description ?? `#${args.task.id}`) : `#${args.task.id}`;
}
/** Per-recipient permission checker for the goal; fail-closed (empty) on lookup error. */
private async checkerForRecipient(goalId: number, recipient: MessagingRecipient): Promise<GoalPermissionsChecker> {
const permissions = await this.permissionsRepo
.fetchPermissionsForGoalByUser({ goalId, userId: recipient.userId, email: recipient.email })
.catch((err) => {
$logger.error(err, `[Messaging] permission check failed for user=${recipient.userId}`);
return [];
});
return new GoalPermissionsChecker(permissions);
}
private async buildTaskUrl(goalId: number, taskId: number, goalListId: number | null): Promise<string | undefined> {
const orgSlug = await this.repository.fetchGoalOrgSlug(goalId);
return buildTaskDeepLink(orgSlug, goalId, taskId, goalListId);
}
/** "👤 email1, email2" of the task's current assignees, or undefined if none. */
private async assigneesFooter(taskId: number): Promise<string | undefined> {
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
if (collabIds.length === 0) return undefined;
const assignees = await this.repository.resolveCollabIdsToRecipients(collabIds);
if (assignees.length === 0) return undefined;
return `👤 ${assignees.map((a) => a.email).join(', ')}`;
}
private async subscribed(ownerType: string, ownerId: number, event: MessagingEvent): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
const connections = await this.repository.fetchActiveByOwner(ownerType, ownerId);
return connections.filter(c => c.events.includes(event));
}
private async taskAssigneeRecipients(taskId: number, goalId: number): Promise<MessagingRecipient[]> {
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
const memberCollabIds = await this.repository.filterCollabIdsInGoal(collabIds, goalId);
return this.repository.resolveCollabIdsToRecipients(memberCollabIds);
}
private taskCtx(task: { id: number; goalListId: number | null; description: string | null; complete: boolean | null }): MessagingTaskContext {
return { id: task.id, goalListId: task.goalListId, description: task.description, complete: task.complete === true };
}
private async fetchTask(taskId: number) {
const db = Database.getInstance();
const rows = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, taskId)).limit(1);
return rows[0] ?? null;
}
private async fetchSprintName(sprintId: number): Promise<string> {
const db = Database.getInstance();
const rows = await db.dbDrizzle.select({ name: SprintsSchema.name }).from(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).limit(1);
return rows[0]?.name ?? `#${sprintId}`;
}
private async enqueueAll(connections: MessagingConnectionsSchemaTypeForSelect[], message: ReturnType<typeof buildMessagingMessage>): Promise<void> {
const boss = getJobQueue();
for (const connection of connections) {
await boss.send(MESSAGING_DELIVER_JOB, {
connectionId: connection.id,
provider: connection.provider as MessagingProviderId,
chatId: connection.targetChatId,
accessTokenEncrypted: connection.accessTokenEncrypted,
message,
attempt: 1,
} satisfies MessagingDeliverJobData);
}
}
private async deliverJob(data: MessagingDeliverJobData): Promise<void> {
const provider = this.manager.getProvider(data.provider);
if (!provider || !provider.isConfigured()) return;
const result = await provider.deliver({
chatId: data.chatId,
accessToken: data.accessTokenEncrypted ? decrypt(data.accessTokenEncrypted) : null,
message: data.message,
});
$logger.info({ connectionId: data.connectionId, provider: data.provider, success: result.success, errorCode: result.errorCode }, '[Messaging] deliver result');
if (result.success) return;
if (data.attempt < MAX_ATTEMPTS) {
const boss = getJobQueue();
const delay = Math.pow(2, data.attempt) * 5;
await boss.send(MESSAGING_DELIVER_JOB, { ...data, attempt: data.attempt + 1 }, { startAfter: delay });
return;
}
$logger.warn(`[Messaging] Delivery failed for connection=${data.connectionId} after ${MAX_ATTEMPTS} attempts`);
}
}
@@ -0,0 +1,199 @@
import { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { $logger } from '../../modules/logget';
import { MessagingRepository } from './MessagingRepository';
import { TelegramProvider } from './providers/telegram.provider';
import { SlackProvider } from './providers/slack.provider';
import { buildTaskDeepLink } from './utils';
import type { MessagingProvider } from './providers/MessagingProvider';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingConnectionForClient, MessagingConnectLinkResult } from './types.internal';
import { sanitizeMessagingEvents, type MessagingMessage, type MessagingProviderId } from './types';
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
export class MessagingManager {
public readonly repository = new MessagingRepository();
private readonly telegram = new TelegramProvider();
private readonly slack = new SlackProvider();
private readonly providers: Map<MessagingProviderId, MessagingProvider>;
constructor() {
this.providers = new Map<MessagingProviderId, MessagingProvider>([
['telegram', this.telegram],
['slack', this.slack],
]);
}
getProvider(id: MessagingProviderId): MessagingProvider | undefined {
return this.providers.get(id);
}
private async beginConnect(id: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
const provider = this.getProvider(id);
if (!provider?.isConfigured()) return null;
const start = await provider.startConnect(ctx);
if (start.kind === 'deep-link') {
const row = await this.repository.createLinkToken({
token: start.persistToken.token,
provider: id,
ownerType: ctx.ownerType,
ownerId: ctx.ownerId,
createdBy: ctx.userId,
expiresAt: start.persistToken.expiresAt,
});
if (!row) return null;
}
return start;
}
async createPersonalConnectLink(userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'user', ownerId: userId, userId }));
}
async createProjectConnectLink(goalId: number, userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'project', ownerId: goalId, userId }));
}
private toLinkResult(provider: MessagingProviderId, start: ConnectStart | null): MessagingConnectLinkResult | null {
if (start?.kind !== 'deep-link') return null;
return { provider, url: start.url, token: start.persistToken.token, expiresAt: start.persistToken.expiresAt.toISOString() };
}
async startOAuthConnect(provider: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
return this.beginConnect(provider, ctx);
}
async handleInbound(id: MessagingProviderId, raw: InboundRaw): Promise<{ redirect?: string } | null> {
const provider = this.getProvider(id);
if (!provider) return null;
const intent = await provider.parseInbound(raw);
if (!intent) return null;
if (intent.kind === 'createConnection') {
await this.repository.createConnection(intent.connection);
if (intent.identity) await this.repository.upsertIdentity({ userId: intent.identity.userId, provider: id, externalUserId: intent.identity.externalUserId, externalTeamId: intent.identity.externalTeamId });
return { redirect: intent.redirect };
}
if (intent.kind === 'command') {
const reply = await this.runCreateTask(id, intent);
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: reply });
return null;
}
await this.bindByToken(id, provider, intent);
return null;
}
private async runCreateTask(id: MessagingProviderId, intent: Extract<InboundIntent, { kind: 'command' }>): Promise<MessagingMessage> {
const note = (title: string): MessagingMessage => ({ event: 'task.created', title });
const userId = await this.repository.findUserIdByExternalId({ provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
if (!userId) return note('Link your account in TaskView first (Personal → Connect), then try /task again.');
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: id, channelId: intent.chatId, externalTeamId: null });
if (goalIds.length === 0) return note('This chat is not linked to a TaskView project.');
if (goalIds.length > 1) return note('This chat is linked to several projects — create the task in TaskView.');
const appUser = await this.buildAppUser(userId);
if (!appUser) return note('Could not resolve your TaskView account.');
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
return note('You do not have permission to create tasks in this project.');
}
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description: intent.text });
const task = created?.[0];
if (!task) return note('Failed to create the task.');
const orgSlug = await this.repository.fetchGoalOrgSlug(goalIds[0]);
const url = buildTaskDeepLink(orgSlug, goalIds[0], task.id, task.goalListId ?? null);
return { event: 'task.created', title: '✅ Task created', body: intent.text, url };
}
private async buildAppUser(userId: number): Promise<AppUser | null> {
const record = await new AppUser().authManager.repository.fetchUserById(userId);
if (!record || record.block !== 0) return null;
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
}
private async bindByToken(id: MessagingProviderId, provider: MessagingProvider, intent: Extract<InboundIntent, { kind: 'bindByToken' }>): Promise<void> {
const link = await this.repository.findValidLinkToken(id, intent.token);
// Silently drop unknown/expired tokens or a token used in the wrong chat kind — no
// reply, so the endpoint can't be used as an outbound-message amplifier.
if (!link || link.ownerType !== intent.scope) return;
const connection = await this.repository.createConnection({
provider: id,
ownerType: link.ownerType,
ownerId: link.ownerId,
targetChatId: intent.chatId,
title: intent.title,
externalTeamId: null,
accessTokenEncrypted: null,
});
if (!connection) {
$logger.error(`[Messaging] Failed to create ${id} connection for ${link.ownerType}=${link.ownerId}`);
return;
}
// Telegram user IDs are globally unique, so there is no workspace to scope by.
if (intent.scope === 'user') await this.repository.upsertIdentity({ userId: link.ownerId, provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
await this.repository.consumeLinkToken(link.id);
const text = intent.scope === 'user'
? 'Done! Your TaskView account is linked — notifications will be delivered here.'
: 'Done! This chat is connected to your TaskView project — project events will be posted here.';
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: { event: 'task.created', title: text } });
}
async fetchPersonalConnections(userId: number): Promise<MessagingConnectionForClient[]> {
const connections = await this.repository.fetchByOwner('user', userId);
return connections.map(c => this.toClient(c));
}
async togglePersonal(id: number, userId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.setActiveOwned({ id, ownerType: 'user', ownerId: userId, isActive });
return updated ? this.toClient(updated) : null;
}
async deletePersonal(id: number, userId: number): Promise<boolean> {
return this.repository.deleteOwned({ id, ownerType: 'user', ownerId: userId });
}
async updatePersonalEvents(id: number, userId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'user', ownerId: userId, events: sanitizeMessagingEvents(events) });
return updated ? this.toClient(updated) : null;
}
async fetchProjectConnections(goalId: number): Promise<MessagingConnectionForClient[]> {
const connections = await this.repository.fetchByOwner('project', goalId);
return connections.map(c => this.toClient(c));
}
async toggleProject(id: number, goalId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
// goalId was verified against the caller by IsGoalOwnerByGoalId; the WHERE
// clause also binds the connection to that goal, so no cross-project mutation.
const updated = await this.repository.setActiveOwned({ id, ownerType: 'project', ownerId: goalId, isActive });
return updated ? this.toClient(updated) : null;
}
async deleteProject(id: number, goalId: number): Promise<boolean> {
return this.repository.deleteOwned({ id, ownerType: 'project', ownerId: goalId });
}
async updateProjectEvents(id: number, goalId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'project', ownerId: goalId, events: sanitizeMessagingEvents(events) });
return updated ? this.toClient(updated) : null;
}
async updateProjectPostContent(id: number, goalId: number, postContent: boolean): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.setPostContentOwned({ id, ownerType: 'project', ownerId: goalId, postContent });
return updated ? this.toClient(updated) : null;
}
private toClient(connection: MessagingConnectionsSchemaTypeForSelect): MessagingConnectionForClient {
const { accessTokenEncrypted, ...rest } = connection;
return rest;
}
}
@@ -0,0 +1,311 @@
import { createHash } from 'crypto';
import { and, eq, gt, inArray, isNull } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import {
CollaborationUsersSchema,
CollaborationUsersToGoalsSchema,
GoalsSchema,
MessagingConnectionsSchema,
MessagingIdentityMapSchema,
MessagingLinkTokensSchema,
OrganizationsSchema,
TasksAssigneeSchema,
UsersSchema,
type MessagingConnectionsSchemaTypeForSelect,
type MessagingLinkTokensSchemaTypeForSelect,
} from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
import { SLACK_WEBHOOK_PREFIX } from './config';
import type {
MessagingChannelLookup,
MessagingConnectionCreate,
MessagingIdentityLookup,
MessagingIdentityUpsert,
MessagingLinkTokenCreate,
MessagingOwnedRef,
MessagingOwnedToggle,
MessagingRecipient,
} from './types.internal';
export class MessagingRepository {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async createConnection(data: MessagingConnectionCreate): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(MessagingConnectionsSchema)
.values(data)
.onConflictDoUpdate({
target: [
MessagingConnectionsSchema.provider,
MessagingConnectionsSchema.ownerType,
MessagingConnectionsSchema.ownerId,
MessagingConnectionsSchema.targetChatId,
],
set: {
title: data.title,
externalTeamId: data.externalTeamId,
accessTokenEncrypted: data.accessTokenEncrypted,
isActive: true,
updatedAt: new Date(),
},
})
.returning()
);
return result?.[0] ?? null;
}
async fetchById(id: number): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(eq(MessagingConnectionsSchema.id, id))
);
return result?.[0] ?? null;
}
async fetchByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
and(
eq(MessagingConnectionsSchema.ownerType, ownerType),
eq(MessagingConnectionsSchema.ownerId, ownerId),
)
)
);
return result ?? [];
}
async fetchActiveByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
and(
eq(MessagingConnectionsSchema.ownerType, ownerType),
eq(MessagingConnectionsSchema.ownerId, ownerId),
eq(MessagingConnectionsSchema.isActive, true),
)
)
);
return result ?? [];
}
async setActiveOwned(args: MessagingOwnedToggle): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(MessagingConnectionsSchema)
.set({ isActive: args.isActive, updatedAt: new Date() })
.where(this.ownedWhere(args))
.returning()
);
return result?.[0] ?? null;
}
async deleteOwned(args: MessagingOwnedRef): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(MessagingConnectionsSchema).where(this.ownedWhere(args))
);
return !!result?.rowCount;
}
private ownedWhere(args: MessagingOwnedRef) {
return and(
eq(MessagingConnectionsSchema.id, args.id),
eq(MessagingConnectionsSchema.ownerType, args.ownerType),
eq(MessagingConnectionsSchema.ownerId, args.ownerId),
);
}
async resolveCollabIdsToRecipients(collabIds: number[]): Promise<MessagingRecipient[]> {
if (collabIds.length === 0) return [];
const authUsers = alias(UsersSchema, 'auth_users');
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
.from(CollaborationUsersSchema)
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
.where(inArray(CollaborationUsersSchema.id, collabIds))
);
return rows ?? [];
}
async updateEventsOwned(args: MessagingOwnedRef & { events: string[] }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(MessagingConnectionsSchema)
.set({ events: args.events, updatedAt: new Date() })
.where(this.ownedWhere(args))
.returning()
);
return result?.[0] ?? null;
}
async setPostContentOwned(args: MessagingOwnedRef & { postContent: boolean }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(MessagingConnectionsSchema)
.set({ postContent: args.postContent, updatedAt: new Date() })
.where(this.ownedWhere(args))
.returning()
);
return result?.[0] ?? null;
}
async fetchCollabEmail(collabId: number): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ email: CollaborationUsersSchema.email })
.from(CollaborationUsersSchema)
.where(eq(CollaborationUsersSchema.id, collabId))
.limit(1)
);
return rows?.[0]?.email ?? null;
}
async fetchProjectMemberRecipients(goalId: number): Promise<MessagingRecipient[]> {
const authUsers = alias(UsersSchema, 'auth_users');
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
.from(CollaborationUsersToGoalsSchema)
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
);
return rows ?? [];
}
async fetchGoalName(goalId: number): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ name: GoalsSchema.name }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1)
);
return rows?.[0]?.name ?? null;
}
async fetchGoalOrgSlug(goalId: number): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
.from(GoalsSchema)
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
.where(eq(GoalsSchema.id, goalId))
.limit(1)
);
return rows?.[0]?.slug ?? null;
}
async filterCollabIdsInGoal(collabIds: number[], goalId: number): Promise<number[]> {
if (collabIds.length === 0) return [];
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: CollaborationUsersToGoalsSchema.userId })
.from(CollaborationUsersToGoalsSchema)
.where(and(
eq(CollaborationUsersToGoalsSchema.goalId, goalId),
inArray(CollaborationUsersToGoalsSchema.userId, collabIds),
))
);
return (rows ?? []).map(r => r.userId);
}
async fetchCurrentAssigneeCollabIds(taskId: number): Promise<number[]> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ collabUserId: TasksAssigneeSchema.collabUserId })
.from(TasksAssigneeSchema)
.where(eq(TasksAssigneeSchema.taskId, taskId))
);
return (rows ?? []).map(r => r.collabUserId);
}
async createLinkToken(data: MessagingLinkTokenCreate): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(MessagingLinkTokensSchema)
.values({ ...data, token: this.hashToken(data.token) })
.returning()
);
return result?.[0] ?? null;
}
async findValidLinkToken(provider: string, token: string): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingLinkTokensSchema).where(
and(
eq(MessagingLinkTokensSchema.provider, provider),
eq(MessagingLinkTokensSchema.token, this.hashToken(token)),
gt(MessagingLinkTokensSchema.expiresAt, new Date()),
)
)
);
return result?.[0] ?? null;
}
async consumeLinkToken(id: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.delete(MessagingLinkTokensSchema).where(eq(MessagingLinkTokensSchema.id, id))
);
}
async findUserIdByExternalId(args: MessagingIdentityLookup): Promise<number | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: MessagingIdentityMapSchema.userId })
.from(MessagingIdentityMapSchema)
.where(and(
eq(MessagingIdentityMapSchema.provider, args.provider),
eq(MessagingIdentityMapSchema.externalUserId, args.externalUserId),
args.externalTeamId === null
? isNull(MessagingIdentityMapSchema.externalTeamId)
: eq(MessagingIdentityMapSchema.externalTeamId, args.externalTeamId),
))
.limit(1)
);
return rows?.[0]?.userId ?? null;
}
async fetchProjectGoalIdsByChannel(args: MessagingChannelLookup): Promise<number[]> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ goalId: MessagingConnectionsSchema.ownerId })
.from(MessagingConnectionsSchema)
.where(and(
eq(MessagingConnectionsSchema.provider, args.provider),
eq(MessagingConnectionsSchema.ownerType, 'project'),
eq(MessagingConnectionsSchema.targetChatId, args.channelId),
eq(MessagingConnectionsSchema.isActive, true),
args.externalTeamId === null
? isNull(MessagingConnectionsSchema.externalTeamId)
: eq(MessagingConnectionsSchema.externalTeamId, args.externalTeamId),
))
);
return rows?.map((r) => r.goalId) ?? [];
}
async fetchSlackBotTokenEncrypted(teamId: string): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ token: MessagingConnectionsSchema.accessTokenEncrypted })
.from(MessagingConnectionsSchema)
.where(and(
eq(MessagingConnectionsSchema.provider, 'slack'),
eq(MessagingConnectionsSchema.externalTeamId, teamId),
eq(MessagingConnectionsSchema.isActive, true),
))
);
return rows?.map((r) => r.token).find((t): t is string => !!t && !t.startsWith(SLACK_WEBHOOK_PREFIX)) ?? null;
}
async fetchGoalCollabMembers(goalId: number): Promise<{ collabId: number; email: string }[]> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.selectDistinct({ collabId: CollaborationUsersSchema.id, email: CollaborationUsersSchema.email })
.from(CollaborationUsersToGoalsSchema)
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
);
return rows ?? [];
}
async upsertIdentity(args: MessagingIdentityUpsert): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.insert(MessagingIdentityMapSchema)
.values({ userId: args.userId, provider: args.provider, externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() })
.onConflictDoUpdate({
target: [MessagingIdentityMapSchema.userId, MessagingIdentityMapSchema.provider],
set: { externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() },
})
);
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}
@@ -0,0 +1,51 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { MessagingController } from './MessagingController'
import { VerifyTelegramWebhook } from './middlewares/VerifyTelegramWebhook'
import { VerifySlackRequest } from './middlewares/VerifySlackRequest'
import { CanManageProjectMessaging, CanViewProjectMessaging } from './middlewares/ProjectMessagingPermission'
export default class MessagingRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: MessagingController
constructor() {
this.router = Router()
this.controller = new MessagingController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
// Personal (per-user) connections — scoped to the authenticated user.
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch)
this.router.patch('/toggle', [IsLoggedIn, RejectApiTokenAuth], this.controller.toggle)
this.router.patch('/events', [IsLoggedIn, RejectApiTokenAuth], this.controller.updateEvents)
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete)
this.router.get('/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth], this.controller.connectLink)
// Project connections — guarded by project ownership (goalId in body/query).
this.router.get('/project', [IsLoggedIn, RejectApiTokenAuth, CanViewProjectMessaging], this.controller.fetchProject)
this.router.patch('/project/toggle', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.toggleProject)
this.router.patch('/project/events', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectEvents)
this.router.patch('/project/post-content', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectPostContent)
this.router.delete('/project', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.deleteProject)
this.router.get('/project/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.projectConnectLink)
// Slack OAuth — public (auth carried by the JWT ?token on start and the signed state on callback).
this.router.get('/slack/oauth/start', this.controller.slackOAuthStart)
this.router.get('/slack/oauth/callback', this.controller.slackOAuthCallback)
// Inbound webhook — public, protected by the Telegram secret-token header.
this.router.post('/telegram/webhook', [VerifyTelegramWebhook], this.controller.telegramWebhook)
// Slack inbound (slash commands + interactivity) — public, protected by the Slack signature.
this.router.post('/slack/commands', [VerifySlackRequest], this.controller.slackCommands)
this.router.post('/slack/interactivity', [VerifySlackRequest], this.controller.slackInteractivity)
}
}
@@ -0,0 +1,245 @@
import { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { decrypt } from '../../utils/crypto';
import { MessagingRepository } from './MessagingRepository';
import { SlackProvider } from './providers/slack.provider';
import {
SLACK_ACTION_ASSIGN,
SLACK_ACTION_DONE,
SLACK_ACTION_REOPEN,
SLACK_ASSIGN_BLOCK,
SLACK_ASSIGN_SELECT_ACTION,
SLACK_VIEW_ASSIGN_CALLBACK,
} from './providers/slack.constants';
import { buildTaskDeepLink, escapeSlackText } from './utils';
import type { SlackEphemeralReply, SlackInteractionPayload, SlackSlashCommandPayload } from './types.internal';
// Orchestrates inbound Slack actions: /task (create), and Done / Assign buttons.
// Every path resolves the Slack user to a TaskView user (identity map) and enforces
// that user's RBAC before touching anything.
export class SlackInboundManager {
private readonly repository = new MessagingRepository();
private readonly slack = new SlackProvider();
async handleSlashCommand(payload: SlackSlashCommandPayload): Promise<SlackEphemeralReply> {
const userId = await this.resolveUser(payload.user_id, payload.team_id);
if (!userId) return this.ephemeral(this.linkPrompt());
const description = (payload.text ?? '').trim();
if (!description) return this.ephemeral('Usage: /task <description>');
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: 'slack', channelId: payload.channel_id ?? '', externalTeamId: payload.team_id ?? null });
if (goalIds.length === 0) return this.ephemeral('This channel is not linked to a TaskView project.');
if (goalIds.length > 1) return this.ephemeral('This channel is linked to several projects — create the task in TaskView.');
const appUser = await this.buildAppUser(userId);
if (!appUser) return this.ephemeral('Could not resolve your TaskView account.');
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
return this.ephemeral('You do not have permission to create tasks in this project.');
}
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description });
if (!created?.[0]) return this.ephemeral('Failed to create the task.');
return this.ephemeral(`✅ Task created: ${description}`);
}
async handleInteraction(interaction: SlackInteractionPayload): Promise<object | void> {
if (interaction.type === 'view_submission') return this.handleAssignSubmit(interaction);
if (interaction.type === 'block_actions') return this.handleBlockAction(interaction);
}
private async handleBlockAction(i: SlackInteractionPayload): Promise<void> {
const action = i.actions?.[0];
const taskId = Number(action?.value);
if (!action?.action_id || !taskId) return;
const userId = await this.resolveUser(i.user?.id, i.team?.id);
if (!userId) return this.ackEphemeral(i.response_url, this.linkPrompt());
if (action.action_id === SLACK_ACTION_DONE) return this.completeTask(i, userId, taskId);
if (action.action_id === SLACK_ACTION_REOPEN) return this.reopenTask(i, userId, taskId);
if (action.action_id === SLACK_ACTION_ASSIGN) return this.openAssignModal(i, userId, taskId);
}
private async completeTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
const appUser = await this.buildAppUser(userId);
if (!appUser) return;
const task = await appUser.tasksManager.fetchTaskById({ taskId });
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
}
await appUser.tasksManager.updateTask({ id: taskId, complete: true });
// Show which task (description gated by content permission, like notifications) + a link.
const label = checker.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) && task.description
? task.description.slice(0, 200)
: `#${taskId}`;
const orgSlug = await this.repository.fetchGoalOrgSlug(task.goalId);
const url = buildTaskDeepLink(orgSlug, task.goalId, taskId, task.goalListId ?? null);
const text = url ? `✅ Task completed: <${url}|${escapeSlackText(label)}>` : `✅ Task completed: ${escapeSlackText(label)}`;
await this.ackEphemeral(i.response_url, text);
}
private async reopenTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
const appUser = await this.buildAppUser(userId);
if (!appUser) return;
const task = await appUser.tasksManager.fetchTaskById({ taskId });
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
}
await appUser.tasksManager.updateTask({ id: taskId, complete: false });
await this.ackEphemeral(i.response_url, '↩️ Task reopened');
}
private async openAssignModal(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
const appUser = await this.buildAppUser(userId);
if (!appUser) return;
const task = await appUser.tasksManager.fetchTaskById({ taskId });
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
}
const members = await this.repository.fetchGoalCollabMembers(task.goalId);
if (members.length === 0) return this.ackEphemeral(i.response_url, 'This project has no members to assign.');
const botToken = await this.botTokenForTeam(i.team?.id);
if (!botToken || !i.trigger_id) return this.ackEphemeral(i.response_url, 'Slack workspace is not fully connected.');
// Pre-select the current assignees so the modal shows who's assigned and lets the
// user add or remove — submit sets the whole list.
const currentAssignees = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
const metadata = JSON.stringify({ taskId });
await this.slack.openModal({ botToken, triggerId: i.trigger_id, view: this.assignView(members, currentAssignees, metadata) });
}
private async handleAssignSubmit(i: SlackInteractionPayload): Promise<object> {
if (i.view?.callback_id !== SLACK_VIEW_ASSIGN_CALLBACK) return {};
const meta = this.parseMeta(i.view.private_metadata);
const taskId = Number(meta.taskId);
if (!taskId) return {};
const selected = i.view.state?.values?.[SLACK_ASSIGN_BLOCK]?.[SLACK_ASSIGN_SELECT_ACTION]?.selected_options ?? [];
const selectedCollabIds = selected.map((o) => Number(o.value)).filter((n) => Number.isInteger(n) && n > 0);
const userId = await this.resolveUser(i.user?.id, i.team?.id);
if (!userId) return this.viewError(this.linkPrompt());
const appUser = await this.buildAppUser(userId);
if (!appUser) return {};
const task = await appUser.tasksManager.fetchTaskById({ taskId });
if (!task) return {};
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return this.viewError('You do not have permission to assign this task.');
}
// toggleTaskUsers is called directly (not via the HTTP CanUpdateTaskAssignee guard),
// so re-validate every selected id is a member of this goal — Slack doesn't guarantee
// the submitted values are among the options we offered.
const valid = await this.repository.filterCollabIdsInGoal(selectedCollabIds, task.goalId);
if (valid.length !== selectedCollabIds.length) return this.viewError('One of the selected users is not a member of this project.');
// SET the assignee list to exactly the selection — this both adds and removes.
// An empty selection clears everyone (the input block is optional).
await appUser.tasksManager.toggleTaskUsers({ taskId, userIds: selectedCollabIds });
return {}; // closes the modal
}
private async resolveUser(slackUserId?: string, teamId?: string): Promise<number | null> {
if (!slackUserId) return null;
// Scope by workspace: a Slack user id is unique only within its team.
return this.repository.findUserIdByExternalId({ provider: 'slack', externalUserId: slackUserId, externalTeamId: teamId ?? null });
}
private async buildAppUser(userId: number): Promise<AppUser | null> {
const record = await new AppUser().authManager.repository.fetchUserById(userId);
if (!record || record.block !== 0) return null;
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
}
private async botTokenForTeam(teamId?: string): Promise<string | null> {
if (!teamId) return null;
const encrypted = await this.repository.fetchSlackBotTokenEncrypted(teamId);
if (!encrypted) return null;
try {
return decrypt(encrypted);
} catch {
return null;
}
}
private assignView(members: { collabId: number; email: string }[], currentAssignees: number[], privateMetadata: string): object {
const current = new Set(currentAssignees);
const options = members.slice(0, 100).map((m) => ({
text: { type: 'plain_text', text: m.email.slice(0, 75) },
value: String(m.collabId),
}));
const initialOptions = options.filter((o) => current.has(Number(o.value)));
const element: Record<string, unknown> = {
type: 'multi_static_select',
action_id: SLACK_ASSIGN_SELECT_ACTION,
placeholder: { type: 'plain_text', text: 'Select assignees' },
options,
};
// Slack rejects an empty initial_options array — only set it when there are current ones.
if (initialOptions.length > 0) element.initial_options = initialOptions;
return {
type: 'modal',
callback_id: SLACK_VIEW_ASSIGN_CALLBACK,
private_metadata: privateMetadata,
title: { type: 'plain_text', text: 'Assignees' },
submit: { type: 'plain_text', text: 'Save' },
close: { type: 'plain_text', text: 'Cancel' },
blocks: [
{
type: 'input',
block_id: SLACK_ASSIGN_BLOCK,
optional: true, // allow clearing everyone
label: { type: 'plain_text', text: 'Assignees' },
element,
},
],
};
}
private parseMeta(raw?: string): { taskId?: number } {
try {
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
private viewError(message: string): object {
return { response_action: 'errors', errors: { [SLACK_ASSIGN_BLOCK]: message } };
}
private async ackEphemeral(responseUrl: string | undefined, text: string): Promise<void> {
if (responseUrl) await this.slack.respondEphemeral(responseUrl, text);
}
private ephemeral(text: string): SlackEphemeralReply {
return { response_type: 'ephemeral', text };
}
private linkPrompt(): string {
return `Link your Slack account in TaskView first: ${process.env.APP_URL ?? ''}`;
}
}
+21
View File
@@ -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;
+46
View File
@@ -0,0 +1,46 @@
import type { BuildMessagingMessageArgs, MessagingEvent, MessagingMessage } from './types';
type Audience = 'personal' | 'project';
// Delivered message titles. English on the server, matching NotificationMessages —
// the backend has no i18n/per-recipient locale, so notification text is English by
// convention. (The event-selection UI is localized separately via frontend i18n.)
// Personal wording is second-person where it differs from the project-channel wording.
const TITLES: Record<MessagingEvent, { personal: string; project: string }> = {
'task.created': { personal: '[New task]', project: '[New task]' },
'task.assigned': { personal: '[Task assigned to you]', project: '[Task assigned]' },
'task.statusChanged': { personal: '[Task status changed]', project: '[Task status changed]' },
'task.completed': { personal: '[Task completed]', project: '[Task completed]' },
'task.edited': { personal: '[Task edited]', project: '[Task edited]' },
'task.addedToSprint': { personal: '[Task added to sprint]', project: '[Task added to sprint]' },
'task.deleted': { personal: '[Task deleted]', project: '[Task deleted]' },
'sprint.created': { personal: '[Sprint created]', project: '[Sprint created]' },
'sprint.updated': { personal: '[Sprint edited]', project: '[Sprint edited]' },
'sprint.started': { personal: '[Sprint started]', project: '[Sprint started]' },
'sprint.reviewStarted': { personal: '[Sprint review started]', project: '[Sprint review started]' },
'sprint.completed': { personal: '[Sprint completed]', project: '[Sprint completed]' },
'sprint.paused': { personal: '[Sprint paused]', project: '[Sprint paused]' },
'sprint.resumed': { personal: '[Sprint resumed]', project: '[Sprint resumed]' },
'sprint.deleted': { personal: '[Sprint deleted]', project: '[Sprint deleted]' },
'member.added': { personal: '[Member added]', project: '[Member added]' },
'member.removed': { personal: '[Member removed]', project: '[Member removed]' },
'member.rolesChanged': { personal: '[Member roles changed]', project: '[Member roles changed]' },
'time.started': { personal: '[Timer started]', project: '[Timer started]' },
'time.stopped': { personal: '[Timer stopped]', project: '[Timer stopped]' },
'time.logged': { personal: '[Time logged]', project: '[Time logged]' },
'time.updated': { personal: '[Time entry edited]', project: '[Time entry edited]' },
'time.deleted': { personal: '[Time entry deleted]', project: '[Time entry deleted]' },
'recurrence.created': { personal: '[Recurrence created]', project: '[Recurrence created]' },
'recurrence.updated': { personal: '[Recurrence edited]', project: '[Recurrence edited]' },
'recurrence.paused': { personal: '[Recurrence paused]', project: '[Recurrence paused]' },
'recurrence.resumed': { personal: '[Recurrence resumed]', project: '[Recurrence resumed]' },
'recurrence.ended': { personal: '[Recurrence ended]', project: '[Recurrence ended]' },
'recurrence.deleted': { personal: '[Recurrence deleted]', project: '[Recurrence deleted]' },
'recurrence.skipped': { personal: '[Occurrence skipped]', project: '[Occurrence skipped]' },
};
export function buildMessagingMessage(args: BuildMessagingMessageArgs): MessagingMessage {
const base = args.titleOverride ?? TITLES[args.event][args.audience];
const title = args.projectName ? `${base} [${args.projectName}]` : base;
return { event: args.event, title, body: args.body, url: args.url, taskId: args.taskId, footer: args.footer, completed: args.completed, actions: args.actions };
}
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissions, type GoalPermissionType } from '../../../types/auth.types';
function requireProjectPermission(permission: GoalPermissionType) {
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = Number(req.body?.goalId ?? req.query?.goalId);
if (!goalId || Number.isNaN(goalId)) return res.status(400).end();
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
if (!checker.hasPermissions(permission)) return res.status(403).end();
res.locals.messagingGoalId = goalId;
return next();
};
}
export const CanManageProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_MANAGE);
export const CanViewProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_VIEW);
export function getAuthorizedGoalId(res: Response): number {
return res.locals.messagingGoalId as number;
}
@@ -0,0 +1,32 @@
import { createHmac, timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { $logger } from '../../../modules/logget';
const MAX_AGE_SECONDS = 300;
// Verifies inbound Slack requests (slash commands, interactivity) via the app Signing
// Secret: HMAC-SHA256 over `v0:${timestamp}:${rawBody}`, compared timing-safe against the
// X-Slack-Signature header, with a 5-minute timestamp window to blunt replay.
export const VerifySlackRequest = (req: Request, res: Response, next: NextFunction) => {
const secret = process.env.SLACK_SIGNING_SECRET;
if (!secret) {
$logger.error('[Messaging/Slack] SLACK_SIGNING_SECRET is not set — rejecting inbound request');
return res.status(503).end();
}
const signature = req.headers['x-slack-signature'] as string | undefined;
const timestamp = req.headers['x-slack-request-timestamp'] as string | undefined;
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody;
if (!signature || !timestamp || !rawBody) return res.status(401).end();
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) return res.status(401).end();
const expected = `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${rawBody.toString('utf8')}`).digest('hex')}`;
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
if (expectedBuf.length !== signatureBuf.length || !timingSafeEqual(expectedBuf, signatureBuf)) {
return res.status(401).end();
}
return next();
};
@@ -0,0 +1,35 @@
import { timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { $logger } from '../../../modules/logget';
/**
* Telegram sends the value configured via setWebhook's secret_token in this
* header on every update. Reject anything that doesn't match so the public
* inbound endpoint can't be spoofed.
*/
export function VerifyTelegramWebhook(req: Request, res: Response, next: NextFunction) {
const expected = process.env.TELEGRAM_WEBHOOK_SECRET;
if (!expected) {
$logger.warn('[Messaging/Telegram] webhook rejected: TELEGRAM_WEBHOOK_SECRET not set');
return res.status(503).end();
}
const provided = req.header('X-Telegram-Bot-Api-Secret-Token') ?? '';
if (!safeEqual(provided, expected)) {
$logger.warn({ hasHeader: !!req.header('X-Telegram-Bot-Api-Secret-Token') }, '[Messaging/Telegram] webhook rejected: secret mismatch');
return res.status(401).end();
}
return next();
}
function safeEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
try {
return timingSafeEqual(aBuf, bBuf);
} catch {
return false;
}
}
@@ -0,0 +1,27 @@
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingProviderId } from '../types';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw } from '../types.internal';
/**
* Full contract for a messaging provider (Telegram, Slack, …). A provider owns its
* protocol end-to-end — building connect URLs, exchanging OAuth codes, parsing inbound
* payloads, calling the messenger API. It returns normalized data; the manager does the
* business work (persistence, RBAC) and never branches on the concrete provider.
*/
export interface MessagingProvider {
readonly id: MessagingProviderId;
/** Whether this instance has the credentials needed to operate (env/admin config). */
isConfigured(): boolean;
/** Send one message to a chat/channel. Never throws — failures come back as a result. */
deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult>;
/**
* Everything needed to start connecting this owner: an authorize/deep-link URL, plus
* optionally a token to persist (deep-link flows) or a cookie to set (OAuth anti-CSRF).
*/
startConnect(ctx: ConnectContext): Promise<ConnectStart>;
/** Parse a raw inbound payload (webhook / OAuth callback) into a normalized intent, or null. */
parseInbound(raw: InboundRaw): Promise<InboundIntent | null>;
}
@@ -0,0 +1,8 @@
// Slack Block Kit action / callback identifiers, shared between the provider (which
// renders the buttons/modal) and the inbound handler (which routes interactions by them).
export const SLACK_ACTION_DONE = 'tv_task_done';
export const SLACK_ACTION_REOPEN = 'tv_task_reopen';
export const SLACK_ACTION_ASSIGN = 'tv_task_assign';
export const SLACK_VIEW_ASSIGN_CALLBACK = 'tv_assign_submit';
export const SLACK_ASSIGN_BLOCK = 'tv_assign_block';
export const SLACK_ASSIGN_SELECT_ACTION = 'tv_assign_select';
@@ -0,0 +1,290 @@
import { createHash, randomBytes } from 'crypto';
import axios from 'axios';
import jwt from 'jsonwebtoken';
import { $logger } from '../../../modules/logget';
import { encrypt } from '../../../utils/crypto';
import type { MessagingProvider } from './MessagingProvider';
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
import type { MessagingOwnerType } from '../types';
import { SLACK_ACTION_ASSIGN, SLACK_ACTION_DONE, SLACK_ACTION_REOPEN } from './slack.constants';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingOAuthState, SlackOAuthAccessResponse, SlackOAuthExchange, SlackOpenModalArgs } from '../types.internal';
import {
OAUTH_NONCE_MAX_AGE_MS,
OAUTH_STATE_TTL,
SLACK_AUTHORIZE_URL,
SLACK_OAUTH_NONCE_COOKIE,
SLACK_POST_MESSAGE_URL,
SLACK_TOKEN_URL,
SLACK_VIEWS_OPEN_URL,
SLACK_WEBHOOK_PREFIX,
} from '../config';
import { escapeSlackText, isSafeUrl } from '../utils';
/**
* Slack provider. Bot credentials are instance-level (env), like GitHub/GitLab.
* Personal connections DM the installing user (bot token + chat.postMessage);
* project connections use an incoming webhook (channel chosen during install).
*/
export class SlackProvider implements MessagingProvider {
readonly id: MessagingProviderId = 'slack';
isConfigured(): boolean {
return !!process.env.SLACK_CLIENT_ID && !!process.env.SLACK_CLIENT_SECRET && !!process.env.SLACK_CALLBACK_URL;
}
getOAuthUrl(state: string, ownerType: MessagingOwnerType): string {
const clientId = process.env.SLACK_CLIENT_ID;
const redirectUri = process.env.SLACK_CALLBACK_URL;
if (!clientId || !redirectUri) {
throw new Error('Slack integration OAuth is not configured');
}
// Personal → bot posts a DM to the installer. Project → channel is picked at
// install via incoming-webhook, and chat:write lets us post + update messages
// (chat.update) and open modals for the interactive buttons via the bot token.
const scope = ownerType === 'project' ? 'incoming-webhook,chat:write' : 'chat:write';
const params = new URLSearchParams({
client_id: clientId,
scope,
redirect_uri: redirectUri,
state,
});
return `${SLACK_AUTHORIZE_URL}?${params.toString()}`;
}
async exchangeCode(code: string): Promise<SlackOAuthExchange> {
const redirectUri = process.env.SLACK_CALLBACK_URL;
const res = await axios.post<SlackOAuthAccessResponse>(
SLACK_TOKEN_URL,
new URLSearchParams({
client_id: process.env.SLACK_CLIENT_ID ?? '',
client_secret: process.env.SLACK_CLIENT_SECRET ?? '',
code,
redirect_uri: redirectUri ?? '',
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
const data = res.data;
if (!data.ok || !data.access_token) {
throw new Error(`Slack code exchange failed: ${data.error ?? 'unknown'}`);
}
return {
botToken: data.access_token,
teamId: data.team?.id ?? null,
teamName: data.team?.name ?? null,
authedUserId: data.authed_user?.id ?? null,
webhookUrl: data.incoming_webhook?.url ?? null,
webhookChannel: data.incoming_webhook?.channel ?? null,
webhookChannelId: data.incoming_webhook?.channel_id ?? null,
};
}
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
// Anti-CSRF: a nonce is set as a cookie on the initiating browser and its hash is
// carried inside the signed state, verified on callback.
const nonce = randomBytes(32).toString('hex');
const state = jwt.sign(
{
provider: 'slack',
ownerType: ctx.ownerType,
ownerId: ctx.ownerId,
userId: ctx.userId,
nonceHash: this.hashNonce(nonce),
returnPath: ctx.returnPath ?? '',
} as MessagingOAuthState,
process.env.JWT_SIGN as string,
{ expiresIn: OAUTH_STATE_TTL },
);
return {
kind: 'oauth',
url: this.getOAuthUrl(state, ctx.ownerType),
setCookie: { name: SLACK_OAUTH_NONCE_COOKIE, value: nonce, maxAgeMs: OAUTH_NONCE_MAX_AGE_MS },
};
}
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
// Slash commands / interactivity are handled by SlackInboundManager (they need
// multi-step Slack UI). Here we only complete the OAuth connect round-trip.
if (raw.source !== 'oauth-callback') return null;
const { code, state } = (raw.payload as { code?: string; state?: string }) ?? {};
if (!code || !state) return null;
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as MessagingOAuthState;
if (payload.provider !== 'slack') throw new Error('Provider mismatch in OAuth state');
// The state's nonce hash must match the cookie set on the initiating browser.
// Enforced only in production: local dev often splits the frontend (localhost) from
// the public callback (tunnel), where a single browser cookie can't bridge origins.
const nonceOk = !!raw.cookie && this.hashNonce(raw.cookie) === payload.nonceHash;
if (!nonceOk) {
if (process.env.NODE_ENV === 'production') throw new Error('OAuth state/nonce mismatch');
$logger.warn('[Messaging/Slack] Skipping OAuth nonce check (non-production; split-origin dev)');
}
const ex = await this.exchangeCode(code);
if (payload.ownerType === 'project') {
if (!ex.webhookChannelId) throw new Error('Slack did not return the chosen channel');
// Store the bot token (not the webhook URL): posting via chat.postMessage also
// lets us update messages and open modals for the interactive buttons.
return {
kind: 'createConnection',
redirect: payload.returnPath,
connection: {
provider: 'slack',
ownerType: 'project',
ownerId: payload.ownerId,
targetChatId: ex.webhookChannelId,
title: ex.webhookChannel,
externalTeamId: ex.teamId,
accessTokenEncrypted: encrypt(ex.botToken),
},
};
}
if (!ex.authedUserId) throw new Error('Slack did not return the installing user');
return {
kind: 'createConnection',
redirect: payload.returnPath,
identity: { userId: payload.userId, externalUserId: ex.authedUserId, externalTeamId: ex.teamId ?? null },
connection: {
provider: 'slack',
ownerType: 'user',
ownerId: payload.userId,
targetChatId: ex.authedUserId,
title: ex.teamName ? `Slack (${ex.teamName})` : 'Slack',
externalTeamId: ex.teamId,
accessTokenEncrypted: encrypt(ex.botToken),
},
};
}
private hashNonce(nonce: string): string {
return createHash('sha256').update(nonce).digest('hex');
}
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
if (!args.accessToken) return { success: false };
const secret = args.accessToken;
const text = this.render(args.message);
const blocks = this.buildTaskBlocks(args.message);
try {
// Legacy project connections store a webhook URL (no bot token → no interactive
// buttons). Newer connections store a bot token and post via chat.postMessage.
if (secret.startsWith(SLACK_WEBHOOK_PREFIX)) {
const res = await axios.post(secret, { text, blocks }, { timeout: 10000 });
return { success: res.status === 200 };
}
const res = await axios.post<{ ok: boolean; error?: string }>(
SLACK_POST_MESSAGE_URL,
{ channel: args.chatId, text, blocks },
{ headers: { Authorization: `Bearer ${secret}` }, timeout: 10000 },
);
if (!res.data.ok) {
$logger.warn(`[Messaging/Slack] chat.postMessage failed: ${res.data.error ?? 'unknown'} (channel=${args.chatId})`);
}
return { success: res.data.ok, errorCode: res.data.ok ? undefined : 400 };
} catch (err) {
// Never log the raw axios error — its config.url (incoming webhook, itself a
// secret) / config.headers.Authorization (bot token) would land in disk logs.
const status = (err as { response?: { status?: number } })?.response?.status;
$logger.error({ status }, `[Messaging/Slack] Delivery failed to ${args.chatId}`);
return { success: false };
}
}
/** Opens a modal (assignee picker). trigger_id from the interaction expires in ~3s. */
async openModal(args: SlackOpenModalArgs): Promise<boolean> {
return this.callApi(SLACK_VIEWS_OPEN_URL, args.botToken, { trigger_id: args.triggerId, view: args.view });
}
/** Ephemeral feedback for an interaction — posts to the payload's response_url (no token). */
async respondEphemeral(responseUrl: string, text: string): Promise<void> {
// response_url is Slack-supplied; pin its host to the configured webhook host so a
// trust regression can never turn this into an SSRF to an arbitrary URL.
let host: string;
try {
host = new URL(responseUrl).host;
} catch {
return;
}
if (host !== new URL(SLACK_WEBHOOK_PREFIX).host) return;
try {
// replace_original:false — post a separate ephemeral note, never overwrite the card
// the button was on (the response_url default is to replace the original message).
await axios.post(responseUrl, { response_type: 'ephemeral', replace_original: false, text }, { timeout: 10000 });
} catch {
// Best-effort user feedback; nothing to recover if Slack's response_url is unreachable.
}
}
private async callApi(url: string, botToken: string, body: Record<string, unknown>): Promise<boolean> {
try {
const res = await axios.post<{ ok: boolean; error?: string }>(url, body, {
headers: { Authorization: `Bearer ${botToken}` },
timeout: 10000,
});
if (!res.data.ok) $logger.warn(`[Messaging/Slack] ${url.split('/').pop()} failed: ${res.data.error ?? 'unknown'}`);
return res.data.ok;
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
$logger.error({ status }, `[Messaging/Slack] API call failed: ${url.split('/').pop()}`);
return false;
}
}
// Block Kit: a text section plus Done/Assign action buttons for task events. The button
// value carries the taskId so the interaction round-trips it back to us.
private buildTaskBlocks(message: MessagingMessage): unknown[] {
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
const blocks: unknown[] = [];
// Task name as a large header (Slack's biggest text). Header is plain_text only — no
// link/markdown — so the clickable label lives in the section below.
if (message.body) {
blocks.push({ type: 'header', text: { type: 'plain_text', text: message.body.slice(0, 150), emoji: true } });
}
// Event + project label; the whole line links to the task.
const label = escapeSlackText(message.title);
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: url ? `*<${url}|${label}>*` : `*${label}*` } });
// Assignees line above the buttons so the actions sit at the very bottom.
if (message.footer) {
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: escapeSlackText(message.footer) }] });
}
if (message.taskId && message.actions !== false) {
// A completed task offers Reopen; an open task offers Done.
const primary = message.completed
? { type: 'button', text: { type: 'plain_text', text: '↩️ Reopen' }, action_id: SLACK_ACTION_REOPEN, value: String(message.taskId) }
: { type: 'button', text: { type: 'plain_text', text: '✅ Done' }, action_id: SLACK_ACTION_DONE, value: String(message.taskId) };
blocks.push({
type: 'actions',
elements: [
primary,
{ type: 'button', text: { type: 'plain_text', text: '👤 Assign' }, action_id: SLACK_ACTION_ASSIGN, value: String(message.taskId) },
],
});
}
return blocks;
}
private render(message: MessagingMessage): string {
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
const lines: string[] = [];
if (url) {
// Slack link syntax <url|text>: hyperlink the description, or the title
// when there is no description.
lines.push(message.body
? `*${escapeSlackText(message.title)}*\n<${url}|${escapeSlackText(message.body)}>`
: `*<${url}|${escapeSlackText(message.title)}>*`);
} else {
lines.push(`*${escapeSlackText(message.title)}*`);
if (message.body) lines.push(escapeSlackText(message.body));
}
if (message.footer) lines.push(escapeSlackText(message.footer));
return lines.join('\n');
}
}
@@ -0,0 +1,105 @@
import { randomBytes } from 'crypto';
import { $logger } from '../../../modules/logget';
import type { MessagingProvider } from './MessagingProvider';
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, TelegramInboundMessage } from '../types.internal';
import { LINK_TTL_MS, TELEGRAM_API } from '../config';
import { escapeHtml, isSafeUrl } from '../utils';
/**
* Telegram bot provider. The bot token is instance-level (env), so the SaaS
* ships an official bot and self-hosted installs supply their own via
* TELEGRAM_BOT_TOKEN — no code fork, only config.
*/
export class TelegramProvider implements MessagingProvider {
readonly id: MessagingProviderId = 'telegram';
isConfigured(): boolean {
return !!process.env.TELEGRAM_BOT_TOKEN && !!process.env.TELEGRAM_BOT_USERNAME;
}
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return { success: false };
try {
const response = await fetch(`${TELEGRAM_API}/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: args.chatId,
text: this.render(args.message),
parse_mode: 'HTML',
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(10000),
});
return { success: response.ok, errorCode: response.ok ? undefined : response.status };
} catch (err) {
// Log only the message — the raw error can carry the request URL, which
// embeds the bot token.
const message = err instanceof Error ? err.message : 'unknown error';
$logger.error({ message }, `[Messaging/Telegram] Delivery failed to chat=${args.chatId}`);
return { success: false };
}
}
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
const username = process.env.TELEGRAM_BOT_USERNAME;
const token = randomBytes(24).toString('hex');
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
// Personal → deep-link into a private chat (auto-sends /start <token>).
// Project → startgroup lets the admin add the bot to a group; the bind completes
// when they run /connect <token> there.
const url = ctx.ownerType === 'user'
? `https://t.me/${username}?start=${token}`
: `https://t.me/${username}?startgroup=${token}`;
return { kind: 'deep-link', url, persistToken: { token, expiresAt } };
}
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
if (raw.source !== 'webhook') return null;
const message = (raw.payload as { message?: TelegramInboundMessage })?.message;
if (!message) return null;
const text = typeof message.text === 'string' ? message.text.trim() : '';
const chatId = message.chat?.id;
const chatType = message.chat?.type;
const fromId = message.from?.id;
if (!text || text.length > 4096 || typeof chatId !== 'number' || typeof fromId !== 'number') return null;
// /task <description> — create a task in this chat's project.
const taskMatch = text.match(/^\/task(?:@\w+)?\s+([\s\S]+)$/);
if (taskMatch) {
return { kind: 'command', command: 'createTask', text: taskMatch[1].trim().slice(0, 2000), chatId: String(chatId), externalUserId: String(fromId) };
}
const match = text.match(/^\/(?:start|connect)(?:@\w+)?\s+(\S+)$/);
if (!match) return null;
if (chatType === 'private') {
const title = typeof message.from?.username === 'string' ? `@${message.from.username}` : null;
return { kind: 'bindByToken', token: match[1], scope: 'user', chatId: String(chatId), externalUserId: String(fromId), title };
}
if (chatType === 'group' || chatType === 'supergroup') {
const title = typeof message.chat?.title === 'string' ? message.chat.title : null;
return { kind: 'bindByToken', token: match[1], scope: 'project', chatId: String(chatId), externalUserId: String(fromId), title };
}
return null;
}
private render(message: MessagingMessage): string {
// Mirror the Slack layout: task name (bold) on top, the "[event] [project]" label as
// the link below, assignees last. Telegram has no header size, so name is just bold.
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
const lines: string[] = [];
if (message.body) lines.push(`<b>${escapeHtml(message.body)}</b>`);
const label = escapeHtml(message.title);
lines.push(url ? `<a href="${escapeHtml(url)}">${label}</a>` : label);
if (message.footer) lines.push(escapeHtml(message.footer));
return lines.join('\n');
}
}
@@ -0,0 +1,209 @@
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
import type { MessagingEvent, MessagingOwnerType, MessagingProviderId } from './types';
export type MessagingConnectionForClient = Omit<MessagingConnectionsSchemaTypeForSelect, 'accessTokenEncrypted'>;
export interface MessagingRecipient {
userId: number;
email: string;
}
export interface SlackOpenModalArgs {
botToken: string;
triggerId: string;
view: unknown;
}
export interface SlackOAuthAccessResponse {
ok: boolean;
error?: string;
access_token?: string;
team?: { id?: string; name?: string };
authed_user?: { id?: string };
incoming_webhook?: { url?: string; channel?: string; channel_id?: string };
}
export interface SlackSlashCommandPayload {
user_id?: string;
channel_id?: string;
team_id?: string;
text?: string;
}
export interface SlackInteractionPayload {
type?: string;
user?: { id?: string };
team?: { id?: string };
trigger_id?: string;
response_url?: string;
channel?: { id?: string };
container?: { message_ts?: string };
message?: { ts?: string; blocks?: { type?: string; text?: { text?: string } }[] };
actions?: { action_id?: string; value?: string }[];
view?: {
callback_id?: string;
private_metadata?: string;
state?: { values?: Record<string, Record<string, { selected_options?: { value?: string }[] }>> };
};
}
export interface SlackEphemeralReply {
response_type: 'ephemeral';
text: string;
}
export interface TelegramInboundMessage {
text?: unknown;
chat?: { id?: unknown; type?: unknown; title?: unknown };
from?: { id?: unknown; username?: unknown };
}
export interface MessagingTaskContext {
id: number;
goalListId: number | null;
description: string | null;
complete: boolean;
}
export interface MessagingDispatchArgs {
event: MessagingEvent;
goalId: number;
personalRecipients: MessagingRecipient[];
initiatorId: number | null;
/** Simple, non-content body (sprint name, member email, …). */
body?: string;
/** Task context: description is RBAC-gated per recipient (COMPONENT_CAN_WATCH_CONTENT) + a task deep-link. */
task?: MessagingTaskContext;
/** Overrides the title (e.g. "[Task reopened]" while still gated by the task.completed subscription). */
titleOverride?: string;
}
/** Slack identities are keyed by workspace; Telegram passes externalTeamId = null. */
export interface MessagingIdentityUpsert {
userId: number;
provider: string;
externalUserId: string;
externalTeamId: string | null;
}
export interface MessagingIdentityLookup {
provider: string;
externalUserId: string;
externalTeamId: string | null;
}
export interface MessagingChannelLookup {
provider: string;
channelId: string;
externalTeamId: string | null;
}
export interface MessagingConnectionCreate {
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
targetChatId: string;
title: string | null;
externalTeamId: string | null;
accessTokenEncrypted: string | null;
}
export interface MessagingOwnedRef {
id: number;
ownerType: MessagingOwnerType;
ownerId: number;
}
export interface MessagingOwnedToggle extends MessagingOwnedRef {
isActive: boolean;
}
export interface MessagingLinkTokenCreate {
token: string;
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
createdBy: number;
expiresAt: Date;
}
export interface SlackOAuthExchange {
botToken: string;
teamId: string | null;
teamName: string | null;
authedUserId: string | null;
webhookUrl: string | null;
webhookChannel: string | null;
webhookChannelId: string | null;
}
export interface MessagingOAuthState {
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
userId: number;
/** SHA-256 of a nonce also stored in an httpOnly cookie — binds the flow to the initiating browser (anti-CSRF). */
nonceHash: string;
/** In-app path the user started from, to return them there after the callback. */
returnPath: string;
}
export interface MessagingConnectLinkResult {
provider: MessagingProviderId;
url: string;
token: string;
expiresAt: string;
}
// ── Provider lifecycle (single MessagingProvider interface) ─────────────────
// Everything a provider needs to start a connection. The manager stays generic:
// it never branches on provider — it just persists what the provider returns.
export interface ConnectContext {
ownerType: MessagingOwnerType;
ownerId: number;
userId: number;
/** In-app path to return to after an OAuth round-trip (Slack); ignored by others. */
returnPath?: string;
}
// Discriminated so the two flows can't be mixed up: a deep-link provider always mints a
// token to persist; an OAuth provider always sets an anti-CSRF cookie. Never both.
export type ConnectStart =
| { kind: 'deep-link'; url: string; persistToken: { token: string; expiresAt: Date } }
| { kind: 'oauth'; url: string; setCookie: { name: string; value: string; maxAgeMs: number } };
/** Raw inbound, tagged by which endpoint received it so the provider can parse accordingly. */
export interface InboundRaw {
source: 'oauth-callback' | 'webhook';
payload: unknown;
/** Cookie value echoed back for verification (OAuth nonce). */
cookie?: string;
}
/** Normalized inbound outcome. The provider parses protocol; the manager does DB + RBAC. */
export type InboundIntent =
| {
kind: 'createConnection';
connection: MessagingConnectionCreate;
/** Personal connections also link the messenger account to a TaskView user. */
identity?: { userId: number; externalUserId: string; externalTeamId: string | null };
/** In-app path to redirect the browser to (OAuth callback). */
redirect?: string;
}
| {
kind: 'bindByToken';
token: string;
scope: MessagingOwnerType;
chatId: string;
externalUserId: string;
title: string | null;
}
| {
/** A command from a chat (e.g. Telegram /task). The manager resolves identity + RBAC. */
kind: 'command';
command: 'createTask';
text: string;
chatId: string;
externalUserId: string;
};
+162
View File
@@ -0,0 +1,162 @@
import { type } from 'arktype';
export const MESSAGING_PROVIDERS = ['telegram', 'slack'] as const;
export type MessagingProviderId = typeof MESSAGING_PROVIDERS[number];
export const MESSAGING_OWNER_TYPES = ['user', 'project', 'organization'] as const;
export type MessagingOwnerType = typeof MESSAGING_OWNER_TYPES[number];
/**
* Provider-agnostic event kinds the module reacts to. They map onto EventBus
* events in MessagingDispatcher and onto notification types for preferences.
*/
/**
* User-selectable events. Task events target the task's assignees; sprint events
* target project members. Each connection subscribes to the subset it wants.
*/
export const MESSAGING_EVENTS = [
// tasks — audience: the task's assignees (task.deleted → project members)
'task.created',
'task.assigned',
'task.statusChanged',
'task.completed',
'task.edited',
'task.addedToSprint',
'task.deleted',
// sprints — audience: project members
'sprint.created',
'sprint.updated',
'sprint.started',
'sprint.reviewStarted',
'sprint.completed',
'sprint.paused',
'sprint.resumed',
'sprint.deleted',
// members — audience: project members
'member.added',
'member.removed',
'member.rolesChanged',
// time tracking — audience: project members
'time.started',
'time.stopped',
'time.logged',
'time.updated',
'time.deleted',
// recurring rules — audience: project members
'recurrence.created',
'recurrence.updated',
'recurrence.paused',
'recurrence.resumed',
'recurrence.ended',
'recurrence.deleted',
'recurrence.skipped',
] as const;
export type MessagingEvent = typeof MESSAGING_EVENTS[number];
export function sanitizeMessagingEvents(events: string[]): MessagingEvent[] {
const allowed = new Set<string>(MESSAGING_EVENTS);
return [...new Set(events.filter((e) => allowed.has(e)))] as MessagingEvent[];
}
/** Normalized message a provider renders into its own format. */
export interface MessagingMessage {
event: MessagingEvent;
title: string;
body?: string;
url?: string;
/** Present for task events — lets the Slack provider attach Done/Assign action buttons. */
taskId?: number;
/** An extra line rendered at the bottom (e.g. assignees) — part of the original message. */
footer?: string;
/** Task's current completion state — the primary button is Reopen when true, else Done. */
completed?: boolean;
/** Whether to render interactive action buttons (Slack). Suppressed for content-hidden channels. */
actions?: boolean;
}
export interface BuildMessagingMessageArgs {
event: MessagingEvent;
audience: 'personal' | 'project';
body: string;
url?: string;
taskId?: number;
projectName?: string | null;
footer?: string;
completed?: boolean;
/** Overrides the title (keeps the subscription event but shows different wording, e.g. reopened). */
titleOverride?: string;
/** Whether to render interactive action buttons. Defaults to shown; false for content-hidden channels. */
actions?: boolean;
}
export interface MessagingDeliverArgs {
chatId: string;
/** Decrypted per-connection secret (Slack bot token / webhook URL); null for Telegram (instance bot token from env). */
accessToken: string | null;
message: MessagingMessage;
}
export interface MessagingDeliverResult {
success: boolean;
errorCode?: number;
}
export interface MessagingDeliverJobData {
connectionId: number;
provider: MessagingProviderId;
chatId: string;
accessTokenEncrypted: string | null;
message: MessagingMessage;
attempt: number;
}
const NumberFromString = type('string|number').pipe((v) => Number(v));
// Derived from MESSAGING_PROVIDERS so adding a provider doesn't silently fail validation.
const MessagingProviderParam = type.enumerated(...MESSAGING_PROVIDERS);
export const MessagingArkTypeConnectLink = type({
provider: MessagingProviderParam,
});
export type MessagingArgConnectLink = typeof MessagingArkTypeConnectLink.infer;
export const MessagingArkTypeById = type({
id: NumberFromString,
});
export type MessagingArgById = typeof MessagingArkTypeById.infer;
export const MessagingArkTypeToggle = type({
id: 'number',
isActive: 'boolean',
});
export type MessagingArgToggle = typeof MessagingArkTypeToggle.infer;
export const MessagingArkTypeUpdateEvents = type({
id: 'number',
events: 'string[]',
});
export type MessagingArgUpdateEvents = typeof MessagingArkTypeUpdateEvents.infer;
// Project routes take goalId from IsProjectGoalOwner (res.locals), not the payload,
// so these schemas validate only the non-authorization fields.
export const MessagingArkTypeProviderParam = type({
provider: MessagingProviderParam,
});
export type MessagingArgProviderParam = typeof MessagingArkTypeProviderParam.infer;
export const MessagingArkTypeProjectToggle = type({
id: 'number',
isActive: 'boolean',
});
export type MessagingArgProjectToggle = typeof MessagingArkTypeProjectToggle.infer;
export const MessagingArkTypeProjectDelete = type({
id: 'number',
});
export type MessagingArgProjectDelete = typeof MessagingArkTypeProjectDelete.infer;
export const MessagingArkTypeProjectPostContent = type({
id: 'number',
postContent: 'boolean',
});
export type MessagingArgProjectPostContent = typeof MessagingArkTypeProjectPostContent.infer;
+38
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Slack mrkdwn requires escaping these three in text (incl. link labels).
export function escapeSlackText(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
+3
View File
@@ -6,6 +6,7 @@ import { $logger } from '../../modules/logget'
import { logError } from '../../utils/api'
import { generateString, isEmail } from '../../utils/helpers'
import AuthModel from '../auth/AuthModel'
import { GoalsRepository } from '../goals/GoalsRepository'
import { OrganizationRepository } from '../organizations/OrganizationRepository'
import { createSsoProvider } from './providers/provider-factory'
import { SsoRepository } from './SsoRepository'
@@ -17,6 +18,7 @@ export class SsoController {
private readonly ssoRepo = new SsoRepository()
private readonly authModel = new AuthModel()
private readonly orgRepo = new OrganizationRepository()
private readonly goalsRepo = new GoalsRepository()
initiateLogin = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
@@ -75,6 +77,7 @@ export class SsoController {
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
@@ -114,6 +114,7 @@ export class OidcProvider implements SsoProvider {
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: codeVerifier,
expectedState: returnedState,
expectedNonce: storedNonce,
})
const claims = tokens.claims()
@@ -122,10 +123,6 @@ export class OidcProvider implements SsoProvider {
throw new Error('OIDC token missing email claim')
}
if (claims.nonce !== storedNonce) {
throw new Error('Nonce mismatch — possible token replay attack')
}
return {
email: (claims.email as string).toLowerCase(),
externalId: claims.sub,
+9
View File
@@ -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
+336
View File
@@ -0,0 +1,336 @@
---
title: Telegram & Slack Setup
description: Connect Telegram and Slack to TaskView to receive task notifications. Personal direct messages and project-wide group channels, with instance-level bot credentials, OAuth, signed inbound webhooks, and SHA-256 hashed binding tokens.
navigation:
icon: i-lucide-send
---
TaskView messaging integrations deliver task events to messengers. There are two levels of connection:
- **Personal** — a user links their own account and receives direct messages about their tasks (assigned, deadline, etc.).
- **Project** — an admin connects a group/channel, and events for that project are posted to it for the whole team.
The bot credentials are configured **per instance** (via environment variables), so the official TaskView SaaS ships an official bot and self-hosted installs supply their own — no code changes, only configuration.
## Notification model & event delivery
Understanding **who receives what** matters, because it is not "every subscriber gets every event".
### Connection scope
- **Personal** connections are keyed to a **user** (`ownerType = user`), not to a project or organization. One personal connection covers **all** your projects **and** organizations — switching the organization you view in the app does not change delivery. You receive DMs about your tasks everywhere you are a member.
- **Project** connections are keyed to a **project** (`ownerType = project`). They post that project's events to the connected channel/group for the whole team.
### Per-event audience (personal delivery)
Each event is delivered to a specific **audience**. A subscriber receives it only if they are in that audience — the subscription checkbox controls *whether* you get an event you're eligible for, not *who* is eligible.
| Event | Personal recipients |
|---|---|
| **Task created** | **all project members** (a new task usually has no assignees yet) |
| **Task assigned** | the task's **assignees** |
| **Task status changed** | the task's **assignees** |
| **Task completed / reopened** | the task's **assignees** |
| Sprint / member / time-tracking events | the relevant project members / actors |
Key rules:
- **No initiator exclusion.** Messaging is an explicit opt-in feed, so you receive an event even for **your own** action — e.g. completing a task you're assigned to still DMs you. The initiator is not filtered out; they simply receive the event if they are in the audience.
- **Consequence:** for everything **except Task created**, you are notified **only if you are an assignee** of that task. A task with **no assignees** produces **no** personal completed/status notification for anyone (there is no one in the audience). "Not an assignee → not notified" applies to the initiator too — they are just one non-assignee among others.
- **Task reopened** is delivered under the **Task completed** subscription (same checkbox), shown with a `[Task reopened]` title and a ↩️→ Done button.
### Project delivery & content gating
- Project events go to the connected channel/group when the connection is **active** and **subscribed** to that event.
- Delivery is queued (pg-boss) and retried with backoff (up to 3 attempts).
#### The `post content` toggle
A shared channel is **not** an RBAC principal — TaskView can't check the permissions of each channel member (they may not even have a TaskView account). So instead of per-viewer gating, a project connection has one explicit switch, **post content**, set by whoever connected the channel (requires `INTEGRATIONS_CAN_MANAGE`). It decides how much of a task event is broadcast:
| | `post content = true` (default) | `post content = false` |
|---|---|---|
| Task **title** + link | ✅ posted | ✅ posted |
| Task **description** | ✅ posted | ❌ hidden |
| **Assignee** footer (`👤 emails`) | ✅ posted | ❌ hidden |
| **Action buttons** (Done / Reopen / Assign) | ✅ shown | ❌ hidden |
Turn it **off** for a channel whose audience should see only that *something happened* (title + link) without the task's content, assignee emails, or interactive actions. The event itself is still delivered either way — only the content and buttons are suppressed. Buttons are hidden on a content-hidden channel because the Assign action opens a modal listing project members, which would defeat the point of hiding content.
> **Personal** DMs are different: each recipient is a known TaskView user, so the description is gated per recipient by `COMPONENT_CAN_WATCH_CONTENT` and the assignee footer by `TASKS_CAN_WATCH_ASSIGNED_USERS` — and action buttons are always shown (any action still runs under that user's own RBAC).
## Prerequisites
- TaskView API running and reachable over **public HTTPS** (messengers deliver updates/redirects to your API; `localhost` is not reachable from Telegram/Slack — use your production domain or a tunnel for local testing)
- PostgreSQL database with migrations applied
- `.env.taskview` file configured
> **Local testing tunnel:** prefer `cloudflared tunnel --url http://localhost:1401` — it gives a clean HTTPS URL with no interstitial. `ngrok`'s free tier shows a "You are about to visit…" warning page that breaks the browser OAuth redirect (you can't inject the `ngrok-skip-browser-warning` header into Slack's redirect); if you must use ngrok free, open the tunnel URL once in your browser and click **Visit Site** first, or upgrade to a paid plan.
>
> For Slack, the API base the **frontend** calls and `SLACK_CALLBACK_URL` must be the **same HTTPS origin** — the anti-CSRF nonce cookie is set on `oauth/start` and read on the callback, so a `localhost` start + tunnel callback will not work.
---
## 1. Database Migration
The migration creates the required tables (`tasks.messaging_connections`, `tasks.messaging_link_tokens`, `tasks.messaging_identity_map`) automatically. The migration container handles this on startup — no manual steps needed.
---
## 2. Telegram
### 2.1 Create a bot
1. Open [@BotFather](https://t.me/BotFather) in Telegram
2. Send `/newbot` and follow the prompts
3. Copy the **bot token** (looks like `123456789:AA...`) and note the **bot username** (without `@`)
### 2.2 Configure the bot for group commands
Project connections are completed by typing a command **inside a group**, so the bot must be allowed to read group messages:
1. In @BotFather, send `/setprivacy` → choose your bot → **Disable**
(with privacy enabled, members must instead address the bot explicitly: `/connect@your_bot <token>`)
2. Send `/setjoingroups` → choose your bot → **Enable**
### 2.3 Environment variables
Add to `.env.taskview`:
```
TELEGRAM_BOT_TOKEN=123456789:AA-your-bot-token
TELEGRAM_BOT_USERNAME=your_bot # without the leading @
TELEGRAM_WEBHOOK_SECRET=<random-secret> # any long random string
```
Generate a webhook secret:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
> The webhook secret is compared (in constant time) against the `X-Telegram-Bot-Api-Secret-Token` header on every inbound update, so only Telegram can reach the endpoint.
### 2.4 Register the webhook with Telegram
Tell Telegram where to deliver updates. Run once (replace the placeholders):
```bash
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
-d "url=https://<your-api-domain>/module/messaging/telegram/webhook" \
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
```
Verify it took effect:
```bash
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
```
`url` should point at your `/module/messaging/telegram/webhook` and `pending_update_count` should be `0`.
### 2.5 Usage
Restart the API after setting the environment variables, then in TaskView open a project → right-click it in the sidebar → **"Messengers"**.
**Personal (Direct messages)**
1. On the **Personal** tab, click **Connect****Telegram****Get link**
2. Click **Open in Telegram** and press **Start** in the bot chat
3. Your account is linked — you'll now get DMs about your tasks
**Project (Group channel)**
1. On the **Project** tab (requires the `INTEGRATIONS_CAN_MANAGE` permission), click **Connect****Telegram****Get link**
2. Click **Add bot to group** and pick the group, or add the bot manually
3. In that group, send the shown command: `/connect <token>`
4. The group is connected — project events are now posted there
You can toggle any connection on/off or disconnect it from the same page.
**Creating tasks from a group (`/task`)**
In a group linked to **exactly one** project, any member can create a task:
```
/task Buy the domain
```
- Requires the sender to have **linked their personal Telegram account** (Personal tab → Connect → Telegram) — the command runs under their TaskView permissions (`COMPONENT_CAN_ADD_TASKS`).
- The bot replies with the created task and a link. In an unlinked chat, or one linked to several projects, it says so and creates nothing.
- **Privacy mode note:** with the bot's group privacy **enabled**, Telegram only delivers commands addressed to the bot — use `/task@your_bot <description>` (same as `/connect`). Disable privacy (and re-add the bot) to allow a bare `/task`.
---
## 3. Slack
Slack uses per-instance OAuth credentials, mirroring the GitHub/GitLab integration model. Requires `ENCRYPTION_KEY` (tokens are stored AES-256-GCM encrypted).
> **Public HTTPS is required.** Slack must reach your API over HTTPS for OAuth, slash
> commands and interactivity. In local dev use a tunnel (ngrok / cloudflared); note that a
> free ngrok URL changes on every restart — if it changes you must update the Redirect URL,
> the Slash Command / Interactivity Request URLs **and** `SLACK_CALLBACK_URL`, then restart.
### 3.1 Create the app
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App****From scratch** → name it (e.g. `TaskView`) and pick your workspace.
2. **App name vs bot name** (these are two different fields — a common point of confusion):
- **Basic Information → Display Information → App name** — the app's name in the api.slack.com console (the dropdown at the top-left). Purely cosmetic for the console.
- **App Home → App Display Name → Edit → Display Name (Bot Name)** — how the **bot** signs its messages in Slack (e.g. `TaskView`). Changing it only takes effect after a **Reinstall** (step 5).
### 3.2 OAuth & scopes
1. **OAuth & Permissions → Redirect URLs → Add New Redirect URL** — enter exactly your `SLACK_CALLBACK_URL`, then **Save URLs**:
```
https://<your-api-domain>/module/messaging/slack/oauth/callback
```
Slack rejects plain `http://localhost` — HTTPS only.
2. **OAuth & Permissions → Scopes → Bot Token Scopes** — add:
- `chat:write` — post messages (personal DMs + project channels). **Required.**
- `commands` — needed for the `/task` slash command (inbound).
- `chat:write.public` — *optional but recommended*: lets the bot post to **any public channel without being invited**. Without it you must `/invite` the bot into each channel (see step 5).
- `im:write` — *optional*, only if personal DMs fail to open.
> `incoming-webhook` is added automatically when you enable Incoming Webhooks (step 3.3) and is what makes the project-connect channel picker appear.
### 3.3 Features
1. **Incoming Webhooks → Activate Incoming Webhooks: On.** Required for the **project (channel)** connect flow — it makes Slack show a channel picker during install.
2. **Slash Commands → Create New Command:**
- Command: `/task`
- Request URL: `https://<your-api-domain>/module/messaging/slack/commands`
- Short description: `Create a TaskView task`
3. **Interactivity & Shortcuts → On**, Request URL:
```
https://<your-api-domain>/module/messaging/slack/interactivity
```
Powers the **Done / Reopen / Assign** buttons and the assignee modal.
### 3.4 Credentials → environment variables
**Basic Information → App Credentials** — copy the three values:
```
SLACK_CLIENT_ID=<your-client-id>
SLACK_CLIENT_SECRET=<your-client-secret>
SLACK_CALLBACK_URL=https://<your-api-domain>/module/messaging/slack/oauth/callback
SLACK_SIGNING_SECRET=<your-signing-secret>
```
- `SLACK_CALLBACK_URL` must **exactly** match the Redirect URL from step 3.2.
- `SLACK_SIGNING_SECRET` verifies inbound slash commands / interactivity (without it those endpoints return 503).
- Tokens are stored AES-256-GCM encrypted — `ENCRYPTION_KEY` is required.
- Optional endpoint overrides (tests / enterprise proxy): `SLACK_API_BASE_URL`, `SLACK_AUTHORIZE_URL`, `SLACK_WEBHOOK_PREFIX` (see the environment-variables reference).
Then **restart the API.**
### 3.5 Install (and reinstall)
1. **OAuth & Permissions → Install to Workspace** (or *Reinstall to Workspace*).
2. **Reinstall whenever you change scopes** or the bot display name — otherwise changes don't take effect.
3. **Invite the bot into the channel(s)** it should post to:
```
/invite @TaskView
```
`chat.postMessage` fails with `not_in_channel` if the bot isn't a member. Either invite it per channel, or add the `chat:write.public` scope (step 3.2) so it can post to any **public** channel without an invite (private channels always need an invite).
### 3.6 Connect and use
Open **Messengers** in a project:
- **Personal:** Personal tab → **Connect** → **Slack** → **Continue with Slack** → authorize. The bot DMs you about your tasks. This also **links your Slack account** (required for inbound actions to run as you).
- **Project:** Project tab (needs `INTEGRATIONS_CAN_MANAGE`) → **Connect** → **Slack** → **Continue with Slack** → pick the channel during install. Project events post to that channel. **Invite the bot into that channel** (step 3.5).
> **Gotcha — "added an integration to this channel" is NOT enough.** After you pick a channel during install, Slack posts *"added an integration to this channel: TaskView"* there. That only adds the app's incoming webhook — it does **not** make the bot **user** a channel member, and TaskView posts via the bot (`chat.postMessage`, so it can also render buttons). So delivery still fails with `not_in_channel` until you explicitly run **`/invite @TaskView`** in that channel (or use `chat:write.public`). Confirm the connection's channel is the one you meant — the `#channel` you picked in the webhook dropdown is where events go.
**Acting from Slack:**
- `/task <description>` in a channel linked to **exactly one** project → creates a task there. (In a DM or an unlinked channel it replies that the channel isn't linked; a channel linked to several projects is ambiguous and is refused — create it in the app.)
- Under each task notification: **✅ Done** (complete), **↩️ Reopen** (on a completed card), **👤 Assign** (a modal that pre-selects current assignees; add/remove and Save to set the list).
- Every inbound action runs under the invoking user's TaskView RBAC, so they must have linked their Slack account. Requests are verified with the Signing Secret.
> **Notification model:** messengers are a **feed/history** — each event posts a new message. TaskView does **not** edit past cards, so an old card may still show a live button after the task changed elsewhere. Actions are idempotent and always re-check the current state + permissions, so a stale click is safe.
### 3.7 Distribution
By default a Slack app is **private to your workspace**. To let other workspaces install it, enable **Public Distribution** (free); to list it in the Slack Marketplace, pass Slack's review (also free — a review, not a fee). Self-hosted installs keep the app private; the official SaaS uses one distributed app. Creating and distributing an app does **not** require a paid Slack plan.
---
## 4. Full `.env.taskview` Example
```env
# ... existing vars ...
# Telegram messaging integration
TELEGRAM_BOT_TOKEN=123456789:AA-your-bot-token
TELEGRAM_BOT_USERNAME=your_bot
TELEGRAM_WEBHOOK_SECRET=a1b2c3d4e5f6...
# Slack messaging integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_CALLBACK_URL=https://api.taskview.tech/module/messaging/slack/oauth/callback
SLACK_SIGNING_SECRET=
# Required for Slack (encrypted token storage), shared with SSO/other integrations
ENCRYPTION_KEY=a1b2c3d4e5f6... # 64 hex characters
```
---
## 5. SaaS vs Self-Hosted
| | Official SaaS | Self-hosted |
|---|---|---|
| **Telegram** | One official bot, credentials shipped with the instance | Create your own bot via @BotFather and set `TELEGRAM_*` |
| **Slack** | One official distributed app | Create your own Slack app and set `SLACK_*` |
There is no code fork between the two — only the values of the environment variables differ. If the variables are unset, the integration simply does not appear until an admin configures it.
---
## 6. Production Notes
- **Public HTTPS is mandatory** for the inbound webhook. Update the `setWebhook` URL to your production domain.
- **Re-run `setWebhook`** whenever the API domain changes.
- **`TELEGRAM_WEBHOOK_SECRET`**: store securely, never commit to git.
- **Group privacy**: if `/connect` in a group does nothing, the bot's privacy mode is likely still enabled (see step 2.2).
---
## 7. Troubleshooting
**The "Messengers" page shows a 503 / "This messenger is not configured on the server"**
→ `TELEGRAM_BOT_TOKEN` or `TELEGRAM_BOT_USERNAME` is missing; the API was not restarted after setting them.
**Pressing Start / sending `/connect` does nothing**
→ The webhook is not registered or points at the wrong URL. Check `getWebhookInfo` (step 2.4) and confirm the API is reachable over public HTTPS.
**`/connect <token>` in a group is ignored**
→ The bot's privacy mode is enabled. Disable it (step 2.2), or use `/connect@your_bot <token>`.
**"Link is invalid or expired"**
→ Binding tokens are single-use and expire after 15 minutes. Generate a new one from the **Connect** dialog.
**Notifications don't arrive after connecting**
→ Confirm the connection is toggled **on**, and that you are actually assigned to the task. Project posts require an active project connection.
### Slack
**Nothing posts to a linked channel (personal DM works)** — the log shows `chat.postMessage failed: not_in_channel`
→ The bot **user** isn't a member of that channel. Seeing *"added an integration to this channel: TaskView"* is **not** the same as the bot joining — that message is just the incoming webhook. Run **`/invite @TaskView`** (the bot user) in the channel, or add the `chat:write.public` scope and reinstall. The log line names the channel id (`channel=…`) — make sure it's the one you intended.
**`/task` replies "This channel is not linked to a TaskView project"**
→ Run it in a **project channel** (connected via Messengers → Project), not in a DM. A channel linked to **several** projects is refused as ambiguous — create the task in the app.
**Slash command / buttons return an error or nothing**
→ `SLACK_SIGNING_SECRET` is missing (endpoints return 503), the Request URLs don't match your current public host, or the app wasn't reinstalled after enabling `commands` / `chat:write`.
**"You need to link your Slack account"**
→ Inbound actions run under your TaskView permissions. Link it: Personal tab → Connect → Slack.
**Bot still shows the old name in messages**
→ Changing **Display Name (Bot Name)** requires **Reinstall to Workspace**. The console dropdown name is a separate field (Basic Information → App name).
@@ -0,0 +1,83 @@
@startuml messaging-slack
title TaskView — Slack messaging integration (sequence)
actor User
participant "Web\n(frontend)" as Web
participant "Slack" as Slack
participant "API routes\n+ controller" as API
participant "MessagingManager /\nSlackInboundManager" as Mgr
participant "SlackProvider" as Prov
participant "MessagingDispatcher" as Disp
participant "EventBus /\nTasksManager" as Core
queue "pg-boss" as Q
database "PostgreSQL" as DB
== Connect (OAuth) ==
User -> Web: Messengers → Connect Slack
Web -> API: GET /slack/oauth/start (?token, returnPath)
API -> Mgr: startOAuthConnect(ctx)
Mgr -> Prov: startConnect(ctx)
Prov --> API: url + anti-CSRF nonce cookie
API --> User: 302 → Slack authorize
User -> Slack: authorize (project: pick a channel)
Slack -> API: GET /slack/oauth/callback (code, state)
API -> Mgr: handleInbound(source=oauth-callback, cookie=nonce)
Mgr -> Prov: parseInbound → verify state+nonce, exchange code
Prov -> Slack: oauth.v2.access
Slack --> Prov: bot token, team, channel
Prov --> Mgr: {createConnection, identity?}
Mgr -> DB: store connection (encrypted bot token)\n(+ identity_map for personal)
API --> User: 302 back (?messaging=connected)
== Outbound notification ==
User -> Core: create / assign / complete a task
Core -> Disp: EventBus emit task.*
Disp -> DB: resolve recipients + subscribed connections
note right of Disp: description RBAC-gated per recipient\n(COMPONENT_CAN_WATCH_CONTENT)
Disp -> Q: enqueue deliver jobs
Q -> Disp: worker → deliverJob
Disp -> Prov: deliver(message)
Prov -> Slack: chat.postMessage (Block Kit + buttons)
Slack --> User: notification card
alt delivery fails
Disp -> Q: re-enqueue (backoff, max 3)
end
== Inbound: /task ==
User -> Slack: /task <text> (project channel)
Slack -> API: POST /slack/commands
API -> API: VerifySlackRequest (HMAC signature + replay window)
API -> Mgr: handleSlashCommand(payload)
Mgr -> DB: identity (slack user → userId)
Mgr -> DB: channel → project (goalId)
Mgr -> Core: RBAC (COMPONENT_CAN_ADD_TASKS) + addTaskNew
Core --> Mgr: created task
Mgr --> Slack: ephemeral "✅ Task created: <text>" (no link)
Slack --> User: ephemeral reply
== Inbound: Done / Reopen / Assign buttons ==
User -> Slack: click Done / Reopen / Assign
Slack -> API: POST /slack/interactivity
API -> API: VerifySlackRequest (signature)
API -> Mgr: handleInteraction(payload)
Mgr -> DB: identity → userId
alt Assign
Mgr -> Slack: views.open (multi-select modal, current assignees pre-selected)
User -> Slack: pick assignees → Save
Slack -> API: POST /slack/interactivity (view_submission)
API -> Mgr: handleAssignSubmit
Mgr -> Core: RBAC (TASKS_CAN_ASSIGN_USERS) + toggleTaskUsers (set)
Mgr --> Slack: {} → modal closes (no ephemeral)
else Done / Reopen
Mgr -> Core: RBAC (TASKS_CAN_EDIT_STATUS) + updateTask(complete)
Mgr --> Slack: ephemeral ack (response_url, replace_original=false)
Slack --> User: private confirmation
end
note over User, DB
Feed model: each event posts a NEW card.
Past cards are not edited; a stale click is safe
(idempotent action + RBAC re-check).
end note
@enduml
@@ -0,0 +1,75 @@
@startuml messaging-telegram
title TaskView — Telegram messaging integration (sequence)
actor User
participant "Web\n(frontend)" as Web
participant "Telegram" as TG
participant "API routes\n+ controller" as API
participant "MessagingManager" as Mgr
participant "TelegramProvider" as Prov
participant "MessagingDispatcher" as Disp
participant "EventBus /\nTasksManager" as Core
queue "pg-boss" as Q
database "PostgreSQL" as DB
== One-time setup (admin) ==
note over API, TG
Admin runs setWebhook(url, secret_token) once.
Telegram then POSTs every update to
/module/messaging/telegram/webhook.
end note
== Connect (deep-link binding) ==
User -> Web: Messengers → Connect Telegram → Get link
Web -> API: GET /:provider/connect-link (personal or project)
API -> Mgr: createPersonalConnectLink / createProjectConnectLink
Mgr -> Prov: startConnect(ctx)
Prov --> Mgr: t.me deep-link + one-time token
Mgr -> DB: store hashed link token (TTL 15 min)
API --> User: deep-link ("Add bot to group") + /connect <token>
User -> TG: /start <token> (private) or /connect <token> (group)
TG -> API: POST /telegram/webhook
API -> API: VerifyTelegramWebhook (secret token, constant-time)
API -> Mgr: handleInbound(source=webhook)
Mgr -> Prov: parseInbound → {bindByToken, scope}
Mgr -> DB: findValidLinkToken → create connection\n(+ identity_map for personal), consume token
Mgr -> Prov: deliver("Done! connected")
Prov -> TG: sendMessage
TG --> User: confirmation
== Outbound notification ==
User -> Core: create / assign / complete a task
Core -> Disp: EventBus emit task.*
Disp -> DB: resolve recipients + subscribed connections
note right of Disp: description RBAC-gated per recipient\n(COMPONENT_CAN_WATCH_CONTENT)
Disp -> Q: enqueue deliver jobs
Q -> Disp: worker → deliverJob
Disp -> Prov: deliver(message)
Prov -> TG: sendMessage (HTML, link on "[event] [project]")
TG --> User: notification
alt delivery fails
Disp -> Q: re-enqueue (backoff, max 3)
end
== Inbound: /task ==
User -> TG: /task <text> (group; /task@bot if privacy is on)
TG -> API: POST /telegram/webhook
API -> API: VerifyTelegramWebhook (secret token)
API -> Mgr: handleInbound(source=webhook)
Mgr -> Prov: parseInbound → {command: createTask}
Mgr -> DB: identity (telegram user → userId)
Mgr -> DB: group chat → project (goalId)
Mgr -> Core: RBAC (COMPONENT_CAN_ADD_TASKS) + addTaskNew
Core --> Mgr: created task
Mgr -> Prov: deliver("Task created" + link)
Prov -> TG: sendMessage
TG --> User: reply
note over User, DB
Telegram = notifications + binding + /task.
No inline buttons (Done/Assign) — that is Slack-only.
The link is always sent; Telegram itself won't render
a localhost href (needs a public APP_URL).
end note
@enduml
@@ -98,6 +98,23 @@ For connecting GitLab repositories.
| `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab |
| `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API |
## Messaging Integrations (Telegram / Slack)
For delivering task notifications to messengers. See [Telegram & Slack Setup](/docs/integrations/messaging) for a step-by-step guide.
| Variable | Required | Default | Description |
|---|---|---|---|
| `TELEGRAM_BOT_TOKEN` | No | - | Bot token from @BotFather |
| `TELEGRAM_BOT_USERNAME` | No | - | Bot username without `@` (used to build deep-links) |
| `TELEGRAM_WEBHOOK_SECRET` | No | - | Secret verified against Telegram's `X-Telegram-Bot-Api-Secret-Token` header on inbound updates |
| `SLACK_CLIENT_ID` | No | - | Slack app client ID |
| `SLACK_CLIENT_SECRET` | No | - | Slack app client secret |
| `SLACK_CALLBACK_URL` | No | - | OAuth callback URL (must match the Slack app redirect URL) |
| `SLACK_SIGNING_SECRET` | No | - | Slack request signing secret — verifies inbound slash commands / interactivity (`/task`, Done/Assign buttons) |
| `SLACK_API_BASE_URL` | No | `https://slack.com/api` | Override the Slack Web API base (testing / enterprise proxy) |
| `SLACK_AUTHORIZE_URL` | No | `https://slack.com/oauth/v2/authorize` | Override the Slack OAuth authorize URL |
| `SLACK_WEBHOOK_PREFIX` | No | `https://hooks.slack.com/` | Override the incoming-webhook URL prefix (used to detect legacy webhook connections) |
## Notifications
Optional configuration for real-time and push notification delivery.
@@ -227,6 +244,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oa
ENCRYPTION_KEY=
# Messaging integrations
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
TELEGRAM_WEBHOOK_SECRET=
# SLACK_CLIENT_ID=
# SLACK_CLIENT_SECRET=
# SLACK_SIGNING_SECRET=
# Notifications (optional)
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
# CENTRIFUGO_API_URL=http://centrifugo:8000
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.48.4",
"version": "1.49.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -44,4 +44,4 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0"
}
}
}
@@ -44,6 +44,7 @@ export type GoalItem = {
dateCreation: string | null;
organizationId: number | null;
estimateUnit: 'hours' | 'points';
isInbox: boolean;
};
export type GoalArgItemAdd = Pick<GoalItem, 'name'> & Partial<Pick<GoalItem, 'description' | 'color' | 'organizationId'>>;
@@ -0,0 +1,81 @@
import TvApiBase from './base';
import type { AppResponse } from '@/api/base.types';
import type {
MessagingArgDelete,
MessagingArgProjectDelete,
MessagingArgProjectToggle,
MessagingArgToggle,
MessagingConnectLinkResult,
MessagingConnectionItem,
MessagingProviderId,
} from './messaging.types';
export default class TvMessagingApi extends TvApiBase {
protected moduleUrl = '/module/messaging';
public async fetch() {
return this.request(
this.$axios.get<AppResponse<MessagingConnectionItem[]>>(`${this.moduleUrl}`)
);
}
public async connectLink(provider: MessagingProviderId) {
return this.request(
this.$axios.get<AppResponse<MessagingConnectLinkResult>>(`${this.moduleUrl}/${provider}/connect-link`)
);
}
public async toggle(data: MessagingArgToggle) {
return this.request(
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/toggle`, data)
);
}
public async updateEvents(data: { id: number; events: string[] }) {
return this.request(
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/events`, data)
);
}
public async delete(data: MessagingArgDelete) {
return this.request(
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}`, { data })
);
}
public async fetchProject(goalId: number) {
return this.request(
this.$axios.get<AppResponse<MessagingConnectionItem[]>>(`${this.moduleUrl}/project`, { params: { goalId } })
);
}
public async projectConnectLink(provider: MessagingProviderId, goalId: number) {
return this.request(
this.$axios.get<AppResponse<MessagingConnectLinkResult>>(`${this.moduleUrl}/project/${provider}/connect-link`, { params: { goalId } })
);
}
public async toggleProject(data: MessagingArgProjectToggle) {
return this.request(
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/project/toggle`, data)
);
}
public async updateProjectEvents(data: { id: number; goalId: number; events: string[] }) {
return this.request(
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/project/events`, data)
);
}
public async updateProjectPostContent(data: { id: number; goalId: number; postContent: boolean }) {
return this.request(
this.$axios.patch<AppResponse<MessagingConnectionItem>>(`${this.moduleUrl}/project/post-content`, data)
);
}
public async deleteProject(data: MessagingArgProjectDelete) {
return this.request(
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/project`, { data })
);
}
}
@@ -0,0 +1,79 @@
export type MessagingProviderId = 'telegram' | 'slack';
export type MessagingOwnerType = 'user' | 'project' | 'organization';
export const MESSAGING_EVENTS = [
'task.created',
'task.assigned',
'task.statusChanged',
'task.completed',
'task.edited',
'task.addedToSprint',
'task.deleted',
'sprint.created',
'sprint.updated',
'sprint.started',
'sprint.reviewStarted',
'sprint.completed',
'sprint.paused',
'sprint.resumed',
'sprint.deleted',
'member.added',
'member.removed',
'member.rolesChanged',
'time.started',
'time.stopped',
'time.logged',
'time.updated',
'time.deleted',
'recurrence.created',
'recurrence.updated',
'recurrence.paused',
'recurrence.resumed',
'recurrence.ended',
'recurrence.deleted',
'recurrence.skipped',
] as const;
export type MessagingEvent = typeof MESSAGING_EVENTS[number];
export type MessagingConnectionItem = {
id: number;
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
targetChatId: string;
title: string | null;
externalTeamId: string | null;
events: string[];
postContent: boolean;
isActive: boolean;
createdAt: string | null;
updatedAt: string | null;
};
export type MessagingConnectLinkResult = {
provider: MessagingProviderId;
url: string;
token: string;
expiresAt: string;
};
export type MessagingArgToggle = {
id: number;
isActive: boolean;
};
export type MessagingArgDelete = {
id: number;
};
export type MessagingArgProjectToggle = {
id: number;
goalId: number;
isActive: boolean;
};
export type MessagingArgProjectDelete = {
id: number;
goalId: number;
};
@@ -12,6 +12,7 @@ export * from '@/api/kanban.types';
export * from '@/api/integrations.types';
export * from '@/api/notifications.api.types';
export * from '@/api/webhooks.types';
export * from '@/api/messaging.types';
export * from '@/api/api-tokens.types';
export * from '@/api/sessions.types';
export * from '@/api/organizations.types';
+5
View File
@@ -9,6 +9,7 @@ import TvIntegrationsApi from "./api/integrations";
import TvKanban from "./api/kanban";
import TvNotificationsApi from "./api/notifications";
import TvWebhooks from "./api/webhooks";
import TvMessagingApi from "./api/messaging";
import TvApiTokens from "./api/api-tokens";
import TvSessions from "./api/sessions";
import TvOrganizationsApi from "./api/organizations";
@@ -43,6 +44,8 @@ export class TvApi {
public webhooks: TvWebhooks;
public messaging: TvMessagingApi;
public apiTokens: TvApiTokens;
public sessions: TvSessions;
@@ -84,6 +87,8 @@ export class TvApi {
this.webhooks = new TvWebhooks(this.$axios);
this.messaging = new TvMessagingApi(this.$axios);
this.apiTokens = new TvApiTokens(this.$axios);
this.sessions = new TvSessions(this.$axios);
@@ -13,6 +13,7 @@ export * from './schemas/notifications.schema';
export * from './schemas/device-tokens.schema';
export * from './schemas/notification-preferences.schema';
export * from './schemas/webhooks.schema';
export * from './schemas/messaging.schema';
export * from './schemas/api-tokens.schema';
export * from './schemas/user-tokens.schema';
export * from './schemas/organizations.schema';
@@ -1,4 +1,4 @@
import { integer, pgSchema, time, varchar } from "drizzle-orm/pg-core";
import { integer, pgSchema, time, uniqueIndex, varchar } from "drizzle-orm/pg-core";
import { GoalsSchema } from "./goals.schema";
import { PermissionsSchema } from "./users.schema";
@@ -26,7 +26,9 @@ export const CollaborationPermissionsToRoleSchema = pgSchema('collaboration').ta
export const CollaborationUsersToGoalsSchema = pgSchema('collaboration').table('users_to_goals', {
userId: integer('user_id').notNull().references(() => CollaborationUsersSchema.id, { onDelete: 'cascade' }),
goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
});
}, (table) => [
uniqueIndex('users_to_goals_user_goal_uidx').on(table.userId, table.goalId),
]);
export const CollaborationUsersToRolesSchema = pgSchema('collaboration').table('users_to_roles', {
userId: integer('user_id').notNull().references(() => CollaborationUsersSchema.id, { onDelete: 'cascade' }),
@@ -1,4 +1,4 @@
import { integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
import { boolean, integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
import { UsersSchema } from "./users.schema";
import { OrganizationsSchema } from "./organizations.schema";
// import { createInsertSchema, createSelectSchema } from "drizzle-arktype";
@@ -16,6 +16,7 @@ export const GoalsSchema = pgSchema('tasks').table('goals', {
backlogVersion: integer('backlog_version').default(1),
organizationId: integer('organization_id').references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
estimateUnit: varchar('estimate_unit', { length: 10 }).$type<'hours' | 'points'>().notNull().default('points'),
isInbox: boolean('is_inbox').notNull().default(false),
});
export type GoalsSchemaTypeForSelect = typeof GoalsSchema.$inferSelect;
@@ -0,0 +1,58 @@
import { boolean, integer, pgSchema, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core";
import { UsersSchema } from "./users.schema";
export const MessagingConnectionsSchema = pgSchema('tasks').table('messaging_connections', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
provider: varchar({ length: 20 }).notNull(),
ownerType: varchar('owner_type', { length: 20 }).notNull(),
ownerId: integer('owner_id').notNull(),
targetChatId: varchar('target_chat_id', { length: 255 }).notNull(),
title: varchar({ length: 255 }),
externalTeamId: varchar('external_team_id', { length: 255 }),
accessTokenEncrypted: varchar('access_token_encrypted'),
events: varchar().array().notNull().default(['task.created', 'task.assigned', 'task.statusChanged', 'task.completed']),
// Project channels only: whether to include the RBAC-gated task description in the
// channel message. Default on — a channel is a deliberate broadcast; owners who care
// about COMPONENT_CAN_WATCH_CONTENT turn it off. Personal DMs always gate per-recipient.
postContent: boolean('post_content').notNull().default(true),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => [
uniqueIndex('messaging_connection_unique').on(table.provider, table.ownerType, table.ownerId, table.targetChatId),
]);
// Pending binding intent for any owner (user / project / organization). A user
// generates a token in TaskView, then redeems it from the messenger (deep-link
// for personal, /connect in a group for project). Consumed on redemption.
export const MessagingLinkTokensSchema = pgSchema('tasks').table('messaging_link_tokens', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
token: varchar({ length: 128 }).notNull().unique(),
provider: varchar({ length: 20 }).notNull(),
ownerType: varchar('owner_type', { length: 20 }).notNull(),
ownerId: integer('owner_id').notNull(),
createdBy: integer('created_by').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
// Pure identity: which external account belongs to which TaskView user. Populated
// when a personal connection is bound; reused later to resolve inbound commands.
export const MessagingIdentityMapSchema = pgSchema('tasks').table('messaging_identity_map', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
provider: varchar({ length: 20 }).notNull(),
externalUserId: varchar('external_user_id', { length: 255 }),
// Slack user IDs are unique only WITHIN a workspace, so identity must be keyed by
// (provider, team, user). Null for Telegram, whose user IDs are globally unique.
externalTeamId: varchar('external_team_id', { length: 255 }),
linkedAt: timestamp('linked_at'),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
uniqueIndex('messaging_identity_unique').on(table.userId, table.provider),
]);
export type MessagingConnectionsSchemaTypeForSelect = typeof MessagingConnectionsSchema.$inferSelect;
export type MessagingConnectionsSchemaTypeForInsert = typeof MessagingConnectionsSchema.$inferInsert;
export type MessagingLinkTokensSchemaTypeForSelect = typeof MessagingLinkTokensSchema.$inferSelect;
export type MessagingIdentityMapSchemaTypeForSelect = typeof MessagingIdentityMapSchema.$inferSelect;
@@ -0,0 +1,24 @@
{
"name": "taskview",
"displayName": "TaskView",
"version": "0.1.0",
"description": "Manage TaskView projects and tasks from Claude Code — browse projects, view and create tasks, and log your work without leaving the terminal. Bundles the TaskView MCP server.",
"author": {
"name": "Nikolai Giman"
},
"userConfig": {
"taskview_url": {
"type": "string",
"title": "TaskView API URL",
"description": "TaskView Cloud is https://api.taskview.tech — or your own self-hosted instance URL (e.g. https://taskview.yourcompany.com).",
"required": true
},
"taskview_token": {
"type": "string",
"title": "TaskView API token",
"description": "An API token (tvk_...) generated in your TaskView account settings. Scope it to the minimum permissions and projects the assistant should be able to reach.",
"sensitive": true,
"required": true
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "${user_config.taskview_url}",
"TASKVIEW_TOKEN": "${user_config.taskview_token}"
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# TaskView plugin for Claude Code
Browse and manage your [TaskView](https://taskview.tech) projects and tasks directly from Claude Code. The plugin bundles the [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) server plus skills and slash commands, so setup is just "install and paste a token" — no manual config editing.
## What you get
- **Bundled MCP server** — the full TaskView tool surface (projects, lists, tasks, tags, kanban, collaboration, dependencies, notifications), no separate install.
- **Slash commands:**
- `/taskview-projects` — list your projects
- `/taskview-tasks [project]` — show tasks in a project
- `/taskview-new-task <description>` — create a task
- `/taskview-log [project]` — log the work done this session as a task
- **Skill** — conventions so the assistant resolves ids correctly, paginates, and logs completed work as top-level tasks.
## Requirements
- Node.js >= 24 (the bundled MCP server runs via `npx`)
- A TaskView API token (`tvk_...`) from your account settings — see [API tokens](https://taskview.tech/docs/features/api-tokens)
## Install
```text
/plugin marketplace add Gimanh/taskview-community
/plugin install taskview@taskview
```
On install you'll be prompted for two values:
| Setting | Example | Notes |
|---|---|---|
| **TaskView API URL** | `https://api.taskview.tech` | TaskView Cloud, or your self-hosted instance URL |
| **TaskView API token** | `tvk_...` | Stored securely in your system keychain. Scope it to the minimum permissions/projects needed. |
That's it — the `taskview` MCP server and the commands become available after the plugin is enabled.
## Security
The token is supplied via the plugin's secure `userConfig` (marked sensitive → stored in the OS keychain, not in plaintext settings) and passed to the MCP server only as an environment variable. Every tool call still goes through the full TaskView API stack — authentication, permission checks, and token-scope intersection. Give the token only the scope the assistant should have.
+13
View File
@@ -0,0 +1,13 @@
---
description: Log the work done in this session as a TaskView task
---
Record what was accomplished in the current Claude Code session as a TaskView task, using the `taskview` MCP tools.
1. Summarize the concrete work completed in this session in 13 short lines.
2. Ask the user which project to log it under (or use the one they name in $ARGUMENTS), and resolve its id with `list_goals`.
3. Create **one top-level task** with `create_task` — description = the summary. Set priority/deadline only if the user asks.
4. Show the created task and ask whether to break it into subtasks or add tags.
5. Add task note if needed.
Confirm the project before creating. Do not create the task in a guessed project, and do not create duplicates — check `list_tasks` if unsure.
@@ -0,0 +1,14 @@
---
description: Create a task in TaskView
---
Create a TaskView task using the `taskview` MCP tools.
Request: $ARGUMENTS
1. Parse the request for: task description, target project, optional list, priority (1=low, 2=medium, 3=high), and deadline.
2. Resolve the project id with `list_goals` (and the list id with `list_lists` if a list is named). If the project is missing or ambiguous, ask — do not guess.
3. Call `create_task` with the resolved ids and parsed fields.
4. Confirm what was created (project, description, priority, deadline) and offer to add tags or assignees.
Never create a task in a guessed/unconfirmed project.
@@ -0,0 +1,11 @@
---
description: List your TaskView projects (goals)
---
List the user's TaskView projects using the `taskview` MCP tools.
1. If the user has more than one organization, resolve it with `list_organizations` and ask which one if unclear.
2. Call `list_goals` and present the projects as a short list: name, and a hint of activity if available.
3. Offer next actions — e.g. "view tasks in a project" (`/taskview-tasks`) or "create a task" (`/taskview-new-task`).
Do not invent project names or ids — only show what the tools return.
@@ -0,0 +1,14 @@
---
description: Show tasks in a TaskView project
---
Show the user's TaskView tasks using the `taskview` MCP tools.
Arguments (optional): a project name to scope to — $ARGUMENTS
1. Resolve the project: call `list_goals` and match $ARGUMENTS to a project. If empty or ambiguous, ask which project.
2. Optionally scope to a list with `list_lists` + the `componentId` if the user names one.
3. Call `list_tasks` (0-based `page`, ~30/page) — page through until a page returns fewer than 30. Pass `showCompleted` only if the user wants completed tasks. Use `sortBy` with `descending` when ordering by date or priority.
4. Present a concise checklist: status, description, priority (1=low/2=med/3=high), deadline.
Resolve every id first — never guess project/list ids.
+51
View File
@@ -0,0 +1,51 @@
---
name: taskview
description: Conventions for browsing and managing TaskView projects, lists, and tasks through the bundled taskview MCP tools
---
# Working with TaskView
This plugin connects Claude Code to a TaskView instance through the `taskview`
MCP server. Use it to let the user view and manage their work without leaving
the terminal.
## Terminology
- A **goal** IS a **project** — the words are interchangeable. Every tool/param
that says "goal" (`goalId`, `list_goals`, `create_goal`) means a project. When
the user says "project", use the `*_goal` tools.
## Data model (top → bottom)
- **Organization** — workspace grouping projects and members.
- **Project (goal)** — `goalId`; `list_goals` / `create_goal` / `update_goal`.
- **List (component)** — a section inside a project; pass `componentId` to
`list_tasks` to scope to one list, omit it to see all tasks.
- **Task** — unit of work; supports subtasks, assignees, tags, priority
(1=low, 2=medium, 3=high), deadlines, and dependencies.
## Rules that prevent mistakes
- **Resolve IDs first, never guess them.** Map a name to its numeric id with the
matching `list_*` tool (project → `list_goals`, list → `list_lists`,
members → `list_collaborators_for_goal`, columns → `list_kanban_columns`,
tags → `list_tags`), then pass that id to create/update/delete tools.
- **`list_tasks` is paginated** — `page` is 0-based (~30/page). Keep requesting
the next page until one returns fewer than 30. Completed tasks are hidden
unless `showCompleted` is set. Use `sortBy` (`date` | `priority`) with
`descending` to order.
- **Ask before destructive actions** (`delete_*`) and before creating duplicates
— check with a `list_*` call first.
## Presenting results
- Show tasks as a concise checklist: status, description, priority, deadline.
- When something is ambiguous (which project/list/org), ask one short question
rather than guessing.
## Logging completed work
When the user asks to record what was done, create **one top-level task per
finished unit of work** in the relevant project (resolve it first), with a clear
description and, if known, the list and priority. Do not bury the summary as a
subtask unless the user asks for that.
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.48.4",
"version": "1.49.0",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
@@ -91,4 +91,4 @@
"vue-tsc": "^3.2.2"
},
"packageManager": "pnpm@10.28.1"
}
}
@@ -105,6 +105,7 @@ import { useGoalsStore } from '@/stores/goals.store'
import { useBaseScreenStore } from '@/stores/base-screen.store'
import { $ls, $tvApi } from '@/plugins/axios'
import { useTaskView } from '@/composables/useTaskView'
import { useInbox } from '@/composables/useInbox'
import { useWeekStart } from '@/composables/useWeekStart'
const weekStart = useWeekStart()
@@ -136,6 +137,7 @@ const { t } = useI18n()
const goalsStore = useGoalsStore()
const baseScreenStore = useBaseScreenStore()
const { isMobile } = useTaskView()
const { inbox } = useInbox()
const taskDescription = ref(props.taskName)
const selectedGoalId = ref<number | undefined>(undefined)
@@ -161,18 +163,13 @@ watch(model, async (isOpen) => {
)
}
// Pre-select the last used project. Read fresh from storage on every open so
// it stays in sync across the multiple dialog instances on the screen.
const activeGoals = goalsStore.goals.filter(g => !g.archive)
// Use the last chosen project (it may be the Inbox); otherwise default to the
// Inbox — that's exactly what it's for.
const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY)
const savedId = saved ? Number(saved) : undefined
const savedGoal = savedId ? activeGoals.find(g => g.id === savedId) : undefined
const savedGoal = savedId ? goalsStore.goals.find(g => !g.archive && g.id === savedId) : undefined
if (savedGoal) {
selectedGoalId.value = savedGoal.id
} else if (activeGoals.length === 1) {
selectedGoalId.value = activeGoals[0].id
}
selectedGoalId.value = savedGoal ? savedGoal.id : inbox.value?.id
} else {
// Reset form on close
taskDescription.value = props.taskName
@@ -181,14 +178,16 @@ watch(model, async (isOpen) => {
}
})
const goalOptions = computed(() =>
goalsStore.goals
.filter(g => !g.archive)
.map(g => ({
label: g.name,
value: g.id,
})),
)
const goalOptions = computed(() => {
const active = goalsStore.goals.filter(g => !g.archive)
const options = active
.filter(g => !g.isInbox)
.map(g => ({ label: g.name, value: g.id }))
if (inbox.value) {
options.unshift({ label: t('projects.inbox'), value: inbox.value.id })
}
return options
})
const formattedDeadline = computed(() => {
if (!deadline.value) return ''
@@ -22,7 +22,7 @@ import { useI18n } from 'vue-i18n'
const goalsStore = useGoalsStore()
const { t } = useI18n()
const noGoals = computed(() => goalsStore.goals.filter((g) => !g.archive).length === 0)
const noGoals = computed(() => goalsStore.goals.filter((g) => !g.archive && !g.isInbox).length === 0)
async function addProject(name: string) {
await goalsStore.addGoal({ name })
@@ -0,0 +1,188 @@
<template>
<div class="p-4">
<UTabs
v-model="activeTab"
:items="tabs"
class="w-full"
:ui="{ list: 'rounded-2xl', trigger: 'rounded-xl', indicator: 'rounded-xl' }"
>
<template #personal>
<div class="flex items-center justify-between mb-4 mt-2">
<p class="text-sm text-muted">
{{ t('messaging.personalHint') }}
</p>
<UButton
:label="t('messaging.connect')"
icon="i-lucide-plus"
color="primary"
@click="openConnect('user')"
/>
</div>
<div
v-if="store.personalLoading"
class="flex items-center justify-center h-32"
>
<p>{{ t('common.loading') }}</p>
</div>
<div
v-else-if="store.personal.length === 0"
class="flex flex-col items-center justify-center h-64 text-muted"
>
<UIcon
name="i-lucide-send"
class="size-12 mb-4"
/>
<p>{{ t('messaging.personalEmpty') }}</p>
</div>
<div
v-else
class="flex flex-col gap-3"
>
<MessagingConnectionItem
v-for="item in store.personal"
:key="item.id"
:connection="item"
:can-manage="true"
@toggle="(isActive) => store.togglePersonal(item.id, isActive)"
@delete="handleDeletePersonal(item.id)"
@update-events="(events) => store.updatePersonalEvents(item.id, events)"
/>
</div>
</template>
<template #project>
<div
v-if="!hasProject"
class="flex flex-col items-center justify-center h-64 text-muted"
>
<UIcon
name="i-lucide-folder"
class="size-12 mb-4"
/>
<p>{{ t('messaging.selectProject') }}</p>
</div>
<div
v-else-if="!canViewIntegrations"
class="flex flex-col items-center justify-center h-64 text-muted"
>
<UIcon
name="i-lucide-lock"
class="size-12 mb-4"
/>
<p>{{ t('messaging.noAccess') }}</p>
</div>
<template v-else>
<div class="flex items-center justify-between mb-4 mt-2">
<p class="text-sm text-muted">
{{ t('messaging.projectHint') }}
</p>
<UButton
v-if="canManageIntegrations"
:label="t('messaging.connect')"
icon="i-lucide-plus"
color="primary"
@click="openConnect('project')"
/>
</div>
<div
v-if="store.projectLoading"
class="flex items-center justify-center h-32"
>
<p>{{ t('common.loading') }}</p>
</div>
<div
v-else-if="store.project.length === 0"
class="flex flex-col items-center justify-center h-64 text-muted"
>
<UIcon
name="i-lucide-send"
class="size-12 mb-4"
/>
<p>{{ t('messaging.projectEmpty') }}</p>
</div>
<div
v-else
class="flex flex-col gap-3"
>
<MessagingConnectionItem
v-for="item in store.project"
:key="item.id"
:connection="item"
:can-manage="canManageIntegrations"
@toggle="(isActive) => store.toggleProject(item.id, projectId, isActive)"
@delete="handleDeleteProject(item.id)"
@update-events="(events) => store.updateProjectEvents(item.id, projectId, events)"
@update-post-content="(postContent) => store.updateProjectPostContent(item.id, projectId, postContent)"
/>
</div>
</template>
</template>
</UTabs>
<MessagingConnectModal
v-model:open="isConnectOpen"
:scope="connectScope"
:goal-id="projectId"
@connected="refresh"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessagingStore } from '@/stores/messaging.store'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import MessagingConnectionItem from './parts/MessagingConnectionItem.vue'
import MessagingConnectModal from './parts/MessagingConnectModal.vue'
const { t } = useI18n()
const store = useMessagingStore()
const { projectId, hasProject } = useAppRouteInfo()
const { canViewIntegrations, canManageIntegrations } = useGoalPermissions()
const activeTab = ref('project')
const isConnectOpen = ref(false)
const connectScope = ref<'user' | 'project'>('user')
const tabs = computed(() => [
{ label: t('messaging.tabs.project'), slot: 'project', value: 'project' },
{ label: t('messaging.tabs.personal'), slot: 'personal', value: 'personal' },
])
function openConnect(scope: 'user' | 'project') {
connectScope.value = scope
isConnectOpen.value = true
}
function refresh() {
if (connectScope.value === 'project') fetchProject()
else store.fetchPersonal()
}
function fetchProject() {
if (hasProject.value && canViewIntegrations.value) store.fetchProject(projectId.value)
}
async function handleDeletePersonal(id: number) {
await store.deletePersonal(id)
}
async function handleDeleteProject(id: number) {
await store.deleteProject(id, projectId.value)
}
onMounted(() => store.fetchPersonal())
// Also react to canViewIntegrations: on a cold page load the goal permissions arrive
// after mount, so watching projectId alone would fetch before we're allowed and never retry.
watch([projectId, canViewIntegrations], fetchProject, { immediate: true })
</script>
@@ -0,0 +1,196 @@
<template>
<UModal
v-model:open="open"
:fullscreen="isMobile"
>
<template #header>
<h3 class="text-lg font-semibold">
{{ t('messaging.connectTitle') }}
</h3>
</template>
<template #body>
<div
v-if="step === 'select'"
class="flex flex-col gap-3"
>
<p class="text-sm text-muted">
{{ t('messaging.connectDescription') }}
</p>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="flex flex-col items-center gap-2 p-4 border rounded-lg transition-colors"
:class="provider === 'telegram' ? 'border-primary bg-primary/5' : 'border-default hover:border-primary'"
@click="provider = 'telegram'"
>
<UIcon
name="i-lucide-send"
class="size-8 text-primary"
/>
<span class="font-medium">{{ t('messaging.telegram') }}</span>
</button>
<button
type="button"
class="flex flex-col items-center gap-2 p-4 border rounded-lg transition-colors"
:class="provider === 'slack' ? 'border-primary bg-primary/5' : 'border-default hover:border-primary'"
@click="provider = 'slack'"
>
<UIcon
name="i-lucide-slack"
class="size-8 text-primary"
/>
<span class="font-medium">{{ t('messaging.slack') }}</span>
</button>
</div>
</div>
<div
v-else
class="flex flex-col gap-4"
>
<p class="text-sm">
{{ scope === 'project' ? t('messaging.projectInstructions') : t('messaging.personalInstructions') }}
</p>
<div
v-if="scope === 'project'"
class="flex flex-col gap-1"
>
<span class="text-xs text-muted">{{ t('messaging.command') }}</span>
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
<span class="font-mono text-sm break-all">/connect {{ link?.token }}</span>
<UButton
icon="i-lucide-copy"
variant="ghost"
size="xs"
class="shrink-0"
@click="copy(`/connect ${link?.token}`)"
/>
</div>
</div>
<UButton
:label="scope === 'project' ? t('messaging.addBotToGroup') : t('messaging.openInTelegram')"
icon="i-lucide-external-link"
color="primary"
block
@click="openLink"
/>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
v-if="step === 'select'"
:label="t('common.cancel')"
variant="ghost"
color="neutral"
@click="open = false"
/>
<UButton
v-if="step === 'select'"
:label="provider === 'slack' ? t('messaging.continueWithSlack') : t('messaging.getLink')"
color="primary"
variant="soft"
:loading="loading"
@click="proceed"
/>
<UButton
v-else
:label="t('common.done')"
color="primary"
variant="outline"
@click="finish"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import type { MessagingConnectLinkResult, MessagingProviderId } from 'taskview-api'
import { useMessagingStore } from '@/stores/messaging.store'
import { useTaskView } from '@/composables/useTaskView'
import { useTaskViewMainUrl } from '@/composables/useTaskViewMainUrl'
import $api from '@/helpers/axios'
const props = defineProps<{
scope: 'user' | 'project'
goalId: number
}>()
const emit = defineEmits<{ connected: [] }>()
const open = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const { isMobile } = useTaskView()
const { copy } = useClipboard()
const store = useMessagingStore()
const toast = useToast()
const step = ref<'select' | 'link'>('select')
const provider = ref<MessagingProviderId>('telegram')
const link = ref<MessagingConnectLinkResult | null>(null)
const loading = ref(false)
watch(open, (isOpen) => {
if (isOpen) {
step.value = 'select'
provider.value = 'telegram'
link.value = null
}
})
function proceed() {
if (provider.value === 'slack') {
startSlackOAuth()
return
}
requestLink()
}
function startSlackOAuth() {
const baseUrl = useTaskViewMainUrl()
const token = $api.defaults.headers.common['Authorization']?.toString().replace('Bearer ', '') || ''
// returnPath brings the user back to this in-app page after the OAuth callback,
// instead of dumping them on the app root (which shows the login screen).
const params = new URLSearchParams({ scope: props.scope, token, returnPath: window.location.pathname })
if (props.scope === 'project') params.set('goalId', String(props.goalId))
window.location.href = `${baseUrl}/module/messaging/slack/oauth/start?${params.toString()}`
}
async function requestLink() {
loading.value = true
try {
const result =
props.scope === 'project'
? await store.connectProject(provider.value, props.goalId)
: await store.connectPersonal(provider.value)
if (!result) {
toast.add({ title: t('messaging.notConfigured'), color: 'error' })
return
}
link.value = result
step.value = 'link'
} catch {
toast.add({ title: t('messaging.connectFailed'), color: 'error' })
} finally {
loading.value = false
}
}
function openLink() {
if (link.value?.url) window.open(link.value.url, '_blank')
}
function finish() {
open.value = false
emit('connected')
}
</script>
@@ -0,0 +1,140 @@
<template>
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
<div class="flex items-center gap-3 min-w-0">
<UIcon
:name="providerIcon"
class="size-6 shrink-0"
:class="connection.isActive ? 'text-primary' : 'text-muted'"
/>
<div class="min-w-0">
<p class="font-medium truncate">
{{ connection.title || connection.targetChatId }}
</p>
<div class="flex items-center gap-2 mt-1">
<UBadge
variant="subtle"
size="xs"
>
{{ providerLabel }}
</UBadge>
<span class="text-xs text-muted">
{{ t('messaging.eventsCount', { count: connection.events.length }) }}
</span>
</div>
<div
v-if="isProject && canManage"
class="flex items-center gap-2 mt-2"
>
<USwitch
:model-value="connection.postContent"
size="xs"
@update:model-value="(v: boolean) => emit('update-post-content', v)"
/>
<span
class="text-xs text-muted"
:title="t('messaging.postContentHint')"
>{{ t('messaging.postContent') }}</span>
</div>
</div>
</div>
<div
v-if="canManage"
class="flex items-center gap-2 shrink-0 ml-2"
>
<UButton
icon="i-lucide-sliders-horizontal"
variant="ghost"
size="md"
:title="t('messaging.eventsTitle')"
@click="isEventsOpen = true"
/>
<USwitch
:model-value="connection.isActive"
size="md"
@update:model-value="(v: boolean) => emit('toggle', v)"
/>
<UButton
icon="i-lucide-trash-2"
variant="ghost"
color="error"
size="md"
:title="t('messaging.disconnect')"
@click="isConfirmOpen = true"
/>
</div>
</div>
<MessagingEventsModal
v-model:open="isEventsOpen"
:events="connection.events"
@save="(events) => emit('update-events', events)"
/>
<UModal
v-model:open="isConfirmOpen"
:fullscreen="isMobile"
>
<template #header>
<h3 class="text-lg font-semibold">
{{ t('messaging.disconnect') }}
</h3>
</template>
<template #body>
<p class="text-sm">
{{ t('messaging.disconnectConfirm') }}
</p>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="ghost"
color="neutral"
@click="isConfirmOpen = false"
/>
<UButton
:label="t('messaging.disconnect')"
color="error"
variant="soft"
@click="confirmDelete"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MessagingConnectionItem } from 'taskview-api'
import { useTaskView } from '@/composables/useTaskView'
import MessagingEventsModal from './MessagingEventsModal.vue'
const props = defineProps<{
connection: MessagingConnectionItem
canManage: boolean
}>()
const emit = defineEmits<{
toggle: [isActive: boolean]
delete: []
'update-events': [events: string[]]
'update-post-content': [postContent: boolean]
}>()
const { t } = useI18n()
const { isMobile } = useTaskView()
const isConfirmOpen = ref(false)
const isEventsOpen = ref(false)
const isProject = computed(() => props.connection.ownerType === 'project')
const providerIcon = computed(() => (props.connection.provider === 'slack' ? 'i-lucide-slack' : 'i-lucide-send'))
const providerLabel = computed(() => t(`messaging.${props.connection.provider}`))
function confirmDelete() {
isConfirmOpen.value = false
emit('delete')
}
</script>
@@ -0,0 +1,171 @@
<template>
<UModal
v-model:open="open"
:fullscreen="isMobile"
>
<template #header>
<h3 class="text-lg font-semibold">
{{ t('messaging.eventsTitle') }}
</h3>
</template>
<template #body>
<p class="text-sm text-muted mb-4">
{{ t('messaging.eventsDescription') }}
</p>
<div class="flex flex-col gap-5">
<div
v-for="group in visibleGroups"
:key="group.key"
>
<p class="text-xs font-semibold text-muted uppercase mb-2">
{{ t(`messaging.eventGroups.${group.key}`) }}
</p>
<div class="flex flex-col gap-2">
<label
v-for="event in group.events"
:key="event"
class="flex items-center gap-3 cursor-pointer"
>
<UCheckbox
:model-value="selected.includes(event)"
@update:model-value="toggle(event)"
/>
<span class="text-sm">{{ eventLabel(event) }}</span>
</label>
</div>
</div>
<UButton
v-if="!showAdvanced"
variant="ghost"
color="neutral"
size="sm"
icon="i-lucide-chevron-down"
class="self-start"
@click="showAdvanced = true"
>
{{ t('messaging.showMoreEvents') }}
<span v-if="advancedSelectedCount">({{ advancedSelectedCount }})</span>
</UButton>
<UButton
v-else
variant="ghost"
color="neutral"
size="sm"
icon="i-lucide-chevron-up"
class="self-start"
@click="showAdvanced = false"
>
{{ t('messaging.showLessEvents') }}
</UButton>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="ghost"
color="neutral"
@click="open = false"
/>
<UButton
:label="t('common.save')"
color="primary"
variant="soft"
:loading="saving"
@click="save"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTaskView } from '@/composables/useTaskView'
const props = defineProps<{ events: string[] }>()
const emit = defineEmits<{ save: [events: string[]] }>()
const open = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const { isMobile } = useTaskView()
// Task events are the common ones (shown always); the rest are "noise" hidden
// behind a "show more" toggle.
const GROUPS = [
{ key: 'tasks', advanced: false, events: ['task.created', 'task.assigned', 'task.statusChanged', 'task.completed', 'task.edited', 'task.addedToSprint', 'task.deleted'] },
{ key: 'sprints', advanced: true, events: ['sprint.created', 'sprint.updated', 'sprint.started', 'sprint.reviewStarted', 'sprint.completed', 'sprint.paused', 'sprint.resumed', 'sprint.deleted'] },
{ key: 'members', advanced: true, events: ['member.added', 'member.removed', 'member.rolesChanged'] },
{ key: 'time', advanced: true, events: ['time.started', 'time.stopped', 'time.logged', 'time.updated', 'time.deleted'] },
{ key: 'recurrence', advanced: true, events: ['recurrence.created', 'recurrence.updated', 'recurrence.paused', 'recurrence.resumed', 'recurrence.ended', 'recurrence.deleted', 'recurrence.skipped'] },
]
const LABEL_KEY: Record<string, string> = {
'task.created': 'taskCreated',
'task.assigned': 'taskAssigned',
'task.statusChanged': 'taskStatusChanged',
'task.completed': 'taskCompleted',
'task.edited': 'taskEdited',
'task.addedToSprint': 'taskAddedToSprint',
'task.deleted': 'taskDeleted',
'sprint.created': 'sprintCreated',
'sprint.updated': 'sprintUpdated',
'sprint.started': 'sprintStarted',
'sprint.reviewStarted': 'sprintReviewStarted',
'sprint.completed': 'sprintCompleted',
'sprint.paused': 'sprintPaused',
'sprint.resumed': 'sprintResumed',
'sprint.deleted': 'sprintDeleted',
'member.added': 'memberAdded',
'member.removed': 'memberRemoved',
'member.rolesChanged': 'memberRolesChanged',
'time.started': 'timeStarted',
'time.stopped': 'timeStopped',
'time.logged': 'timeLogged',
'time.updated': 'timeUpdated',
'time.deleted': 'timeDeleted',
'recurrence.created': 'recurrenceCreated',
'recurrence.updated': 'recurrenceUpdated',
'recurrence.paused': 'recurrencePaused',
'recurrence.resumed': 'recurrenceResumed',
'recurrence.ended': 'recurrenceEnded',
'recurrence.deleted': 'recurrenceDeleted',
'recurrence.skipped': 'recurrenceSkipped',
}
const selected = ref<string[]>([])
const saving = ref(false)
const showAdvanced = ref(false)
const advancedEvents = GROUPS.filter((g) => g.advanced).flatMap((g) => g.events)
const advancedSelectedCount = computed(() => selected.value.filter((e) => advancedEvents.includes(e)).length)
const visibleGroups = computed(() => (showAdvanced.value ? GROUPS : GROUPS.filter((g) => !g.advanced)))
watch(open, (isOpen) => {
if (isOpen) {
selected.value = [...props.events]
// Auto-expand if the connection already subscribes to advanced events.
showAdvanced.value = advancedSelectedCount.value > 0
}
})
function eventLabel(event: string): string {
return t(`messaging.eventLabels.${LABEL_KEY[event] ?? event}`)
}
function toggle(event: string) {
selected.value = selected.value.includes(event)
? selected.value.filter((e) => e !== event)
: [...selected.value, event]
}
function save() {
emit('save', [...selected.value])
open.value = false
}
</script>
@@ -33,8 +33,8 @@ const goalsStore = useGoalsStore()
const orgStore = useOrganizationStore()
const { goals } = storeToRefs(goalsStore)
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1))
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0 && !p.isInbox))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1 && !p.isInbox))
watch(() => orgStore.currentOrg, async () => {
@@ -23,6 +23,22 @@
<template #body>
<TaskDetailPanel />
</template>
<template #footer>
<div class="flex justify-between gap-2 w-full">
<UButton
:label="t('common.delete')"
color="error"
variant="soft"
@click="openDeleteDialog"
/>
<UButton
:label="t('common.close')"
color="neutral"
variant="soft"
@click="closeTask"
/>
</div>
</template>
</USlideover>
<!-- Task Detail Modal -->
@@ -19,6 +19,10 @@
<NotificationBell />
</div>
<SidebarInboxLink />
<USeparator />
<ProjectsSidebar />
<template #footer="{ collapsed }">
@@ -32,6 +36,7 @@ import { useI18n } from 'vue-i18n'
import { useDashboard } from '@/composables/useDashboard'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
import UserMenu from '@/components/UserMenu.vue'
@@ -13,7 +13,6 @@
:to="{ name: 'user' }"
variant="taskview"
class="flex-1"
:ui="{ container: 'px-4 py-3' }"
>
{{ t('main') }}
</TvGoalLikeItem>
@@ -22,6 +21,10 @@
</div>
<SearchActivator />
<SidebarInboxLink />
<USeparator />
<SidebarProjectSelect />
<SidebarTools />
<ProjectAddInput
@@ -53,6 +56,7 @@ import UserMenu from '@/components/UserMenu.vue'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
import SidebarProjectSelect from './dashboard-second/SidebarProjectSelect.vue'
import SidebarTools from './dashboard-second/SidebarTools.vue'
import SidebarOrgSelect from './dashboard-second/SidebarOrgSelect.vue'
@@ -0,0 +1,34 @@
<template>
<TvGoalLikeItem
v-if="inbox"
variant="taskview"
:to="{ name: 'user', params: { projectId: inbox.id, listId: ALL_TASKS_LIST_ID } }"
:active="currentProjectId === inbox.id"
>
<div
class="flex items-center gap-2 w-full"
data-testid="sidebar-inbox-link"
>
<UIcon
name="i-lucide-inbox"
class="size-4 shrink-0"
/>
<span>{{ t('projects.inbox') }}</span>
</div>
</TvGoalLikeItem>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { useInbox } from '@/composables/useInbox'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
const { t } = useI18n()
const route = useRoute()
const { inbox } = useInbox()
const currentProjectId = computed(() => Number(route.params.projectId) || null)
</script>
@@ -10,7 +10,7 @@
variant="soft"
size="xl"
block
icon="i-lucide-folder"
:icon="currentProject?.isInbox ? 'i-lucide-inbox' : 'i-lucide-folder'"
trailing-icon="i-lucide-chevron-down"
:ui="{ base: 'rounded-xl', trailingIcon: 'ms-auto' }"
>
@@ -18,7 +18,7 @@
class="flex-1 text-left truncate"
:class="currentProject ? '' : 'text-muted'"
>
{{ currentProject?.name ?? t('projects.selectProject') }}
{{ currentProjectLabel }}
</span>
</UButton>
@@ -89,7 +89,7 @@
class="flex items-center justify-end gap-2"
>
<UButton
v-if="canEditGoal && !isArchived"
v-if="canEditGoal && !isArchived && !isInbox"
icon="i-lucide-archive"
color="neutral"
variant="soft"
@@ -99,7 +99,7 @@
@click="archive"
/>
<UButton
v-if="canEditGoal && isArchived"
v-if="canEditGoal && isArchived && !isInbox"
icon="i-lucide-archive-restore"
color="neutral"
variant="soft"
@@ -109,7 +109,7 @@
@click="restore"
/>
<UButton
v-if="canEditGoal"
v-if="canEditGoal && !isInbox"
icon="i-lucide-pencil"
color="neutral"
variant="soft"
@@ -118,7 +118,7 @@
@click="isEditOpen = true"
/>
<UButton
v-if="canDeleteGoal"
v-if="canDeleteGoal && !isInbox"
icon="i-lucide-trash-2"
color="error"
variant="soft"
@@ -165,12 +165,18 @@ const isEditOpen = ref(false)
const isDeleteOpen = ref(false)
const currentProjectId = computed(() => Number(route.params.projectId) || null)
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1))
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0 && !p.isInbox))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1 && !p.isInbox))
const currentProject = computed<Project | null>(() =>
currentProjectId.value ? goalsStore.goalMap.get(currentProjectId.value) ?? null : null,
)
const isArchived = computed(() => currentProject.value?.archive === 1)
const isInbox = computed(() => currentProject.value?.isInbox === true)
const currentProjectLabel = computed(() =>
currentProject.value
? (currentProject.value.isInbox ? t('projects.inbox') : currentProject.value.name)
: t('projects.selectProject'),
)
const { canEditGoal, canDeleteGoal } = useGoalPermissionsFor(currentProject)
@@ -101,6 +101,7 @@ const tools = computed<Tool[]>(() => {
if (canViewIntegrations.value) {
list.push({ key: 'webhooks', label: t('contextMenu.webhooks'), icon: 'i-lucide-webhook', to: { name: 'webhooks', params: { projectId: id } }, active: () => route.name === 'webhooks' })
list.push({ key: 'integrations', label: t('contextMenu.integrations'), icon: 'i-lucide-plug', to: { name: 'integrations', params: { projectId: id } }, active: () => route.name === 'integrations' })
list.push({ key: 'messaging', label: t('contextMenu.messaging'), icon: 'i-lucide-send', to: { name: 'messaging', params: { projectId: id } }, active: () => route.name === 'messaging' })
}
if (canViewTimeTracking.value) {
list.push({ key: 'timeReports', label: t('contextMenu.timeReports'), icon: 'i-lucide-clock', to: { name: 'project-time-reports', params: { projectId: id } }, active: () => route.name === 'project-time-reports' })
+13
View File
@@ -0,0 +1,13 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useGoalsStore } from '@/stores/goals.store'
import type { Project } from '@/components/features/projects/types'
export const useInbox = () => {
const goalsStore = useGoalsStore()
const { goals } = storeToRefs(goalsStore)
const inbox = computed<Project | null>(() => goals.value.find(g => g.isInbox) ?? null)
return { inbox }
}
+6 -2
View File
@@ -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
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const useTaskView = () => {
const isFullscreenModal = bp.smallerOrEqual('fullscreenModalMax')
return {
hasActiveGoals: computed(() => goalsStore.goals.length > 0),
hasActiveGoals: computed(() => goalsStore.goals.some(g => !g.isInbox)),
isMobile,
isDesktop,
isFullscreenModal,
+377 -14
View File
@@ -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',
},
},
}
+79
View File
@@ -103,6 +103,7 @@ export default {
createNew: 'Create new project',
add: 'Add project',
empty: 'No projects yet',
inbox: 'Inbox',
},
contextMenu: {
tasks: 'Tasks',
@@ -112,6 +113,7 @@ export default {
collaboration: 'Collaboration',
integrations: 'Integrations',
webhooks: 'Webhooks',
messaging: 'Messengers',
timeReports: 'Time reports',
edit: 'Edit',
moveToArchive: 'Move to Archive',
@@ -383,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',
+79 -14
View File
@@ -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',
@@ -103,6 +89,7 @@ export default {
createNew: 'Crear nuevo proyecto',
add: 'Añadir proyecto',
empty: 'Aún no hay proyectos',
inbox: 'Bandeja de entrada',
},
contextMenu: {
tasks: 'Lista',
@@ -112,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',
@@ -383,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',
+79
View File
@@ -103,6 +103,7 @@ export default {
createNew: 'Создать новый проект',
add: 'Добавить проект',
empty: 'Пока нет проектов',
inbox: 'Входящие',
},
contextMenu: {
tasks: 'Список',
@@ -112,6 +113,7 @@ export default {
collaboration: 'Совместная работа',
integrations: 'Интеграции',
webhooks: 'Вебхуки',
messaging: 'Мессенджеры',
timeReports: 'Отчёты по времени',
edit: 'Редактировать',
moveToArchive: 'В архив',
@@ -383,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: 'Добро пожаловать',
+5
View File
@@ -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',
+21
View File
@@ -0,0 +1,21 @@
<template>
<UDashboardPanel id="messaging">
<template #header>
<UDashboardNavbar :title="t('messaging.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<MessagingPanel />
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MessagingPanel from '@/components/features/messaging/MessagingPanel.vue'
const { t } = useI18n()
</script>
+95
View File
@@ -0,0 +1,95 @@
import { defineStore } from 'pinia'
import type { MessagingConnectionItem, MessagingConnectLinkResult, MessagingProviderId } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
interface MessagingStoreState {
personal: MessagingConnectionItem[]
project: MessagingConnectionItem[]
personalLoading: boolean
projectLoading: boolean
}
export const useMessagingStore = defineStore('messaging', {
state: (): MessagingStoreState => ({
personal: [],
project: [],
personalLoading: false,
projectLoading: false,
}),
actions: {
async fetchPersonal(): Promise<void> {
if (this.personalLoading) return
this.personalLoading = true
try {
this.personal = (await $tvApi.messaging.fetch()) || []
} finally {
this.personalLoading = false
}
},
async connectPersonal(provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
return await $tvApi.messaging.connectLink(provider)
},
async togglePersonal(id: number, isActive: boolean): Promise<void> {
const result = await $tvApi.messaging.toggle({ id, isActive })
if (!result) return
const index = this.personal.findIndex((c) => c.id === id)
if (index !== -1) this.personal[index] = result
},
async deletePersonal(id: number): Promise<void> {
const ok = await $tvApi.messaging.delete({ id })
if (!ok) return
this.personal = this.personal.filter((c) => c.id !== id)
},
async updatePersonalEvents(id: number, events: string[]): Promise<void> {
const result = await $tvApi.messaging.updateEvents({ id, events })
if (!result) return
const index = this.personal.findIndex((c) => c.id === id)
if (index !== -1) this.personal[index] = result
},
async fetchProject(goalId: number): Promise<void> {
if (this.projectLoading) return
this.projectLoading = true
try {
this.project = (await $tvApi.messaging.fetchProject(goalId)) || []
} finally {
this.projectLoading = false
}
},
async connectProject(provider: MessagingProviderId, goalId: number): Promise<MessagingConnectLinkResult | null> {
return await $tvApi.messaging.projectConnectLink(provider, goalId)
},
async toggleProject(id: number, goalId: number, isActive: boolean): Promise<void> {
const result = await $tvApi.messaging.toggleProject({ id, goalId, isActive })
if (!result) return
const index = this.project.findIndex((c) => c.id === id)
if (index !== -1) this.project[index] = result
},
async deleteProject(id: number, goalId: number): Promise<void> {
const ok = await $tvApi.messaging.deleteProject({ id, goalId })
if (!ok) return
this.project = this.project.filter((c) => c.id !== id)
},
async updateProjectEvents(id: number, goalId: number, events: string[]): Promise<void> {
const result = await $tvApi.messaging.updateProjectEvents({ id, goalId, events })
if (!result) return
const index = this.project.findIndex((c) => c.id === id)
if (index !== -1) this.project[index] = result
},
async updateProjectPostContent(id: number, goalId: number, postContent: boolean): Promise<void> {
const result = await $tvApi.messaging.updateProjectPostContent({ id, goalId, postContent })
if (!result) return
const index = this.project.findIndex((c) => c.id === id)
if (index !== -1) this.project[index] = result
},
},
})
+1
View File
@@ -12,6 +12,7 @@ export const DEFAULT_GOAL_ITEM: GoalItem = {
dateCreation: null,
organizationId: null,
estimateUnit: 'points',
isInbox: false,
}
export const AllGoalPermissions = TvPermissions
+1 -1
View File
@@ -182,7 +182,7 @@ export default defineConfig({
variant: {
taskview: {
root: 'cursor-pointer shadow-sm bg-tv-ui-bg-elevated',
container: 'sm:p-2 sm:px-4',
container: 'p-2 px-4 sm:p-2 sm:px-4',
},
},