mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15050541fa | |||
| aaa876412d | |||
| 915e8d9f9f | |||
| 55ded8bac9 | |||
| 784652ef5b | |||
| 1f1a1b770f | |||
| e80ab33dda | |||
| 8c7be7362f | |||
| 57ec7c01b6 | |||
| d0f664f78e | |||
| 7bbb36d45e | |||
| 9934bf06d8 | |||
| fd13b33915 | |||
| 64089fd6e7 | |||
| b7a50049bb | |||
| 504ae503dd | |||
| 263883f32d | |||
| 90b55fd82a | |||
| 2e9a4945ce | |||
| 0d70023fb3 | |||
| 645f2e21e5 | |||
| adce016a05 | |||
| b7f2380d45 | |||
| 58eb93e564 | |||
| 4e5e5fb579 | |||
| 314e5a6377 | |||
| 955697f40f | |||
| e5dbba8e2a | |||
| bb28bac9f7 | |||
| 6c990aff4f | |||
| b296046605 | |||
| 40cd8064f5 | |||
| f4a765f40b | |||
| b9ce7261f0 | |||
| bc08c839bc | |||
| 83979ac47c | |||
| a9be0d987a | |||
| 30274069a0 | |||
| 0a8497af59 | |||
| 284a49ce8c | |||
| 64a227303e | |||
| ae88d0f42a | |||
| d40cc0aa1b | |||
| e99d9d5515 | |||
| 9b38e3cd9d | |||
| 9c6d33cefe | |||
| d008fa4f78 | |||
| 8ff7241645 | |||
| 9bdfe679bd | |||
| 4e5a330a27 | |||
| e5860a3816 | |||
| 0f4c484674 | |||
| e025c212e5 | |||
| 9b08c82548 | |||
| 3d9cb5336a | |||
| 8eb94d0d2b | |||
| 78c910f789 |
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ ReleasesBuilds/*
|
||||
iconcreator
|
||||
*.prod.*
|
||||
.DS_Store
|
||||
.cursor
|
||||
.claude
|
||||
node_modules
|
||||
__APP_BUILD__
|
||||
ssl-create
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ ACCESS_LIFE_TIME=1d
|
||||
REFRESH_LIFE_TIME=2d
|
||||
JWT_ALG=HS256
|
||||
|
||||
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
|
||||
#SSO_TRUSTED_DOMAINS=company.com,corp.local
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.domain.com
|
||||
SMTP_PORT=465
|
||||
@@ -27,6 +30,10 @@ SMTP_PASSWORD=your_smtp_password_here
|
||||
SMTP_ENCRYPTION=ssl
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
# Email a person when they are invited to a project (requires SMTP)
|
||||
INVITE_EMAIL_ENABLED=false
|
||||
# Max invite emails one user may trigger per hour (default 30)
|
||||
# INVITE_EMAIL_HOURLY_LIMIT=30
|
||||
|
||||
# Encryption (32-byte hex key for AES-256-GCM)
|
||||
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
@@ -48,6 +55,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Gitea Integration OAuth
|
||||
GITEA_INTEGRATION_CLIENT_ID=
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
# For self-hosted Gitea, override these:
|
||||
# GITEA_BASE_URL=https://gitea.yourcompany.com
|
||||
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
+30
-1
@@ -1,9 +1,38 @@
|
||||
// https://github.com/Gimanh/taskview-community/issues/88
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
instances,
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
autorestart: true,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.48.4",
|
||||
"version": "1.52.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
instances,
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_memory_restart: '1G',
|
||||
env_production: {
|
||||
|
||||
+27
-1
@@ -5,6 +5,9 @@ import { corsMiddleware } from './middlewares/cors';
|
||||
import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import { LoginMethods } from './tv-modules/auth/LoginMethods';
|
||||
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
|
||||
import { PublicApiUrl } from './modules/public-url';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
@@ -13,6 +16,10 @@ export default class App {
|
||||
public port: number;
|
||||
|
||||
constructor(port: number) {
|
||||
LoginMethods.validateOnStartup();
|
||||
PublicApiUrl.validateOnStartup();
|
||||
InviteEmailDispatcher.validateOnStartup();
|
||||
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
|
||||
@@ -31,7 +38,17 @@ export default class App {
|
||||
protected extendApp(): void { }
|
||||
protected extendMiddlewares(): void { }
|
||||
|
||||
private resolveTrustProxy(): boolean | number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw || raw.toLowerCase() === 'false') return false;
|
||||
if (raw.toLowerCase() === 'true') return true;
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private initializeMiddlewares() {
|
||||
this.app.set('trust proxy', this.resolveTrustProxy());
|
||||
|
||||
//add tvJson method, clien need response format like {response: data}
|
||||
this.app.use((_req: Request, res: Response, next) => {
|
||||
res.tvJson = function (data: any) {
|
||||
@@ -51,7 +68,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
|
||||
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
@@ -8,7 +9,7 @@ export interface AppEvents {
|
||||
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
|
||||
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
|
||||
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
@@ -14,6 +16,8 @@ const dispatchers: Dispatcher[] = [
|
||||
new TimeTrackingDispatcher(),
|
||||
new SprintsDispatcher(),
|
||||
new RecurrenceDispatcher(),
|
||||
new MessagingDispatcher(),
|
||||
new InviteEmailDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
|
||||
@@ -640,5 +640,115 @@
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"55": {
|
||||
"version": "1.60.0",
|
||||
"name": "Recurrence schedule mode",
|
||||
"releaseDate": "20260712",
|
||||
"scripts": [
|
||||
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
|
||||
]
|
||||
},
|
||||
"56": {
|
||||
"version": "1.61.0",
|
||||
"name": "Gitea integration provider",
|
||||
"releaseDate": "20260726",
|
||||
"scripts": [
|
||||
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
|
||||
],
|
||||
"description": [
|
||||
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
|
||||
]
|
||||
},
|
||||
"57": {
|
||||
"version": "1.62.0",
|
||||
"name": "Invite email rate limiting",
|
||||
"releaseDate": "20260730",
|
||||
"scripts": [
|
||||
"/1.62.0/0.create-invite-emails.sql"
|
||||
],
|
||||
"description": [
|
||||
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
|
||||
]
|
||||
},
|
||||
"58": {
|
||||
"version": "1.63.0",
|
||||
"name": "SSO domain verification",
|
||||
"releaseDate": "20260813",
|
||||
"scripts": [
|
||||
"/1.63.0/0.sso-domain-verification.sql",
|
||||
"/1.63.0/1.sso-domain-verified-unique.sql"
|
||||
],
|
||||
"description": [
|
||||
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
|
||||
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
|
||||
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
|
||||
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
|
||||
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
|
||||
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Log of sent project-invite emails, used to rate-limit sending:
|
||||
-- a 24h per-recipient cooldown and an hourly cap per initiator.
|
||||
-- Rows older than 24 hours are pruned opportunistically before each insert.
|
||||
CREATE TABLE IF NOT EXISTS collaboration.invite_emails (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tv_auth.sso_configs
|
||||
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tv_auth.sso_configs
|
||||
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
|
||||
ON tv_auth.sso_configs (email_domain_restriction)
|
||||
WHERE domain_verified_at IS NOT NULL;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export class PublicApiUrl {
|
||||
static configured(): string | null {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
return raw.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
static base(req: Request): string {
|
||||
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'ta
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,11 @@ import { z } from 'zod';
|
||||
import { Email } from '../../core/Email';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import {
|
||||
ChangeDefaultUserCredentialsSchema,
|
||||
ChangeOwnPasswordByPasswordSchema,
|
||||
ChangeOwnPasswordSchema,
|
||||
ChangePasswordDataScheme,
|
||||
type PasswordChangeConfirmationMode,
|
||||
ConfirmEmailReqDataSchema,
|
||||
RefreshTokenSchema,
|
||||
RemindPasswordSchema,
|
||||
@@ -15,13 +19,19 @@ import {
|
||||
UserJwtPayloadSchema,
|
||||
} from '../../types/auth.types';
|
||||
import { generateString, isEmail, time } from '../../utils/helpers';
|
||||
import { LoginMethods } from './LoginMethods';
|
||||
import EnEmailTemplate from './mail/confirm-email-en';
|
||||
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;
|
||||
const PASSWORD_CHANGE_CODE_TTL_S = 15 * 60;
|
||||
const PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S = 60;
|
||||
// Seeded by migration 0.0.0 (app_permissions.sql) on self-hosted installs.
|
||||
const DEFAULT_USER_EMAIL = 'test@mail.dest';
|
||||
|
||||
export default class AuthController {
|
||||
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
|
||||
@@ -30,12 +40,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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +153,11 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
if (!(await this.canCreateAccount(req, email))) {
|
||||
$logger.info(`[AuthController:sendLoginCode] public registration disabled, email not invited`);
|
||||
return res.status(403).send({ registrationDisabled: true });
|
||||
}
|
||||
|
||||
const password = this.makeidLogin(7),
|
||||
login = this.makeidLogin(7);
|
||||
|
||||
@@ -215,6 +232,11 @@ export default class AuthController {
|
||||
);
|
||||
|
||||
if (!userData) {
|
||||
if (!(await this.canCreateAccount(req, user.email))) {
|
||||
$logger.info(`[AuthController:loginByProvider] public registration disabled, email not invited`);
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=registration-disabled`);
|
||||
}
|
||||
|
||||
const password = this.makeidLogin(7);
|
||||
const login = this.makeidLogin(7);
|
||||
|
||||
@@ -310,7 +332,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);
|
||||
@@ -418,6 +440,11 @@ export default class AuthController {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
if (!(await this.canCreateAccount(req, email))) {
|
||||
$logger.info(`[AuthController:registration] public registration disabled, email not invited`);
|
||||
return res.status(403).send({ registrationDisabled: true });
|
||||
}
|
||||
|
||||
password = hashSync(password, 10);
|
||||
|
||||
if (!(await this.comparePasswords(passwordRepeat, password))) {
|
||||
@@ -653,6 +680,193 @@ export default class AuthController {
|
||||
return res.json(newTokens);
|
||||
};
|
||||
|
||||
getLoginOptions = async (_req: Request, res: Response) => {
|
||||
return res.status(200).send({
|
||||
magicLink: LoginMethods.isEnabled('magic-link'),
|
||||
password: LoginMethods.isEnabled('password'),
|
||||
sso: LoginMethods.isEnabled('sso'),
|
||||
socialProviders: LoginMethods.availableSocialProviders(),
|
||||
publicRegistration: LoginMethods.publicRegistrationAllowed(),
|
||||
});
|
||||
};
|
||||
|
||||
private canCreateAccount = async (req: Request, email: string): Promise<boolean> => {
|
||||
if (LoginMethods.publicRegistrationAllowed()) return true;
|
||||
return await req.appUser.authManager.repository.isEmailInvited(email);
|
||||
};
|
||||
|
||||
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
|
||||
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
|
||||
}
|
||||
|
||||
getPasswordChangeMode = async (_req: Request, res: Response) => {
|
||||
return res.status(200).send({ mode: this.passwordChangeConfirmationMode() });
|
||||
};
|
||||
|
||||
sendPasswordChangeCode = async (req: Request, res: Response) => {
|
||||
if (this.passwordChangeConfirmationMode() !== 'email') {
|
||||
return res.status(403).send();
|
||||
}
|
||||
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
if (!userEmail) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
|
||||
if (!userData) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sinceLastCode = userData.remind_password_time ? now - userData.remind_password_time : null;
|
||||
if (sinceLastCode !== null && sinceLastCode < PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S) {
|
||||
return res.status(429).send({
|
||||
message: 'Please wait before requesting another code.',
|
||||
retryAfter: PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S - sinceLastCode,
|
||||
});
|
||||
}
|
||||
|
||||
// High-entropy code: the shared remind_password_code column is also redeemable
|
||||
// via the unauthenticated /password/reset endpoint, so a short numeric code
|
||||
// would be brute-forceable there.
|
||||
const code = generateString(12);
|
||||
const saved = await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, code, now);
|
||||
if (!saved) {
|
||||
$logger.error(`Can not save password change code for user ${userData.id}`);
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
const text = `Your TaskView password change code is ${code}\n\nUse this code to confirm your new password. The code expires in 15 minutes.\n\nIf you didn't request this change, ignore this email.`;
|
||||
|
||||
Email.send({
|
||||
text,
|
||||
to: userEmail,
|
||||
subject: `Your TaskView password change code: ${code}`,
|
||||
from: process.env.SMTP_FROM_EMAIL as string,
|
||||
})
|
||||
.then((ok) => {
|
||||
if (!ok) $logger.error({ to: userEmail }, 'Failed to send password change code email');
|
||||
})
|
||||
.catch((err) => $logger.error({ err, to: userEmail }, 'Failed to send password change code email'));
|
||||
|
||||
return res.status(200).end();
|
||||
};
|
||||
|
||||
changeOwnPassword = async (req: Request, res: Response) => {
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
if (!userEmail) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
|
||||
if (!userData) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
if (this.passwordChangeConfirmationMode() === 'password') {
|
||||
const parsedData = ChangeOwnPasswordByPasswordSchema.safeParse(req.body);
|
||||
if (!parsedData.success) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
|
||||
if (!validPassword) {
|
||||
return res.status(403).send({ field: 'currentPassword' });
|
||||
}
|
||||
|
||||
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
|
||||
}
|
||||
|
||||
const parsedData = ChangeOwnPasswordSchema.safeParse(req.body);
|
||||
if (!parsedData.success) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
if (!userData.remind_password_code || !userData.remind_password_time) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now > userData.remind_password_time + PASSWORD_CHANGE_CODE_TTL_S) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
if (userData.remind_password_code !== parsedData.data.code) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, null, null);
|
||||
|
||||
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
|
||||
};
|
||||
|
||||
private async applyNewPassword(res: Response, req: Request, userId: number, newPassword: string) {
|
||||
const passwordHash = hashSync(newPassword, 10);
|
||||
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userId);
|
||||
if (!result) {
|
||||
$logger.error(`Can not update password for user ${userId}`);
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const currentSessionId = req.appUser.getTokenId();
|
||||
await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId);
|
||||
|
||||
return res.status(200).send({ changed: true });
|
||||
}
|
||||
|
||||
changeDefaultUserCredentials = async (req: Request, res: Response) => {
|
||||
const parsedData = ChangeDefaultUserCredentialsSchema.safeParse(req.body);
|
||||
if (!parsedData.success) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
if (!userEmail) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
|
||||
if (!userData || userData.email.toLowerCase() !== DEFAULT_USER_EMAIL) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
|
||||
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
|
||||
if (!validPassword) {
|
||||
return res.status(403).send({ field: 'currentPassword' });
|
||||
}
|
||||
|
||||
const { login, email } = parsedData.data;
|
||||
|
||||
if (login !== userData.login && (await req.appUser.authManager.repository.getUserByLogin(login))) {
|
||||
return res.status(409).send({ field: 'login' });
|
||||
}
|
||||
if (email !== userData.email && (await req.appUser.authManager.repository.getUserByLogin(email, true))) {
|
||||
return res.status(409).send({ field: 'email' });
|
||||
}
|
||||
|
||||
const updated = await req.appUser.authManager.repository.updateUserCredentials({
|
||||
userId: userData.id,
|
||||
oldEmail: userData.email,
|
||||
login,
|
||||
email,
|
||||
passwordHash: hashSync(parsedData.data.password, 10),
|
||||
});
|
||||
if (updated === 'conflict') {
|
||||
return res.status(409).send({ field: 'email' });
|
||||
}
|
||||
if (updated !== 'ok') {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
// JWTs carry login/email and refresh does not re-read them from the DB,
|
||||
// so drop every session and make the user sign in with the new credentials.
|
||||
await req.appUser.authManager.sessionStorage.deleteAllSessions(userData.id);
|
||||
this.clearRefreshToken(res);
|
||||
|
||||
return res.status(200).send({ changed: true });
|
||||
};
|
||||
|
||||
sendDeleteAccountCode = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
const userEmail = req.appUser.getUserData()?.email;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { RegisterUserInDb, UserDbRecord } from '../../types/auth.types';
|
||||
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserEmailArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
|
||||
|
||||
export default class AuthModel {
|
||||
private readonly db: Database;
|
||||
@@ -67,6 +69,28 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async isEmailInvited(email: string): Promise<boolean> {
|
||||
const normalized = email.toLowerCase();
|
||||
try {
|
||||
const orgMembers = await this.db.dbDrizzle
|
||||
.select({ email: OrganizationMembersSchema.email })
|
||||
.from(OrganizationMembersSchema)
|
||||
.where(sql`lower(${OrganizationMembersSchema.email}) = ${normalized}`)
|
||||
.limit(1);
|
||||
if (orgMembers.length > 0) return true;
|
||||
|
||||
const collaborators = await this.db.dbDrizzle
|
||||
.select({ email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.where(sql`lower(${CollaborationUsersSchema.email}) = ${normalized}`)
|
||||
.limit(1);
|
||||
return collaborators.length > 0;
|
||||
} catch (error: unknown) {
|
||||
$logger.error(error, '[AuthModel:isEmailInvited] failed to check invitations');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUserById(id: number): Promise<UserDbRecord | false> {
|
||||
const query = 'SELECT * FROM tv_auth.users WHERE id = $1;';
|
||||
try {
|
||||
@@ -133,6 +157,69 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserCredentials(args: UpdateUserCredentialsArgs): Promise<UpdateUserCredentialsResult> {
|
||||
try {
|
||||
await this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(UsersSchema)
|
||||
.set({ login: args.login, email: args.email, password: args.passwordHash })
|
||||
.where(eq(UsersSchema.id, args.userId));
|
||||
await tx
|
||||
.update(OrganizationMembersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
|
||||
await tx
|
||||
.update(CollaborationUsersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
|
||||
await tx
|
||||
.update(SsoIdentitiesSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(SsoIdentitiesSchema.userId, args.userId));
|
||||
});
|
||||
return 'ok';
|
||||
} catch (error) {
|
||||
// unique(organization_id, email): the new email is already an invited member of one of the user's orgs
|
||||
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
|
||||
if (pgCode === '23505') {
|
||||
return 'conflict';
|
||||
}
|
||||
$logger.error(error, `Can not update credentials for user ${args.userId}`);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserEmail(args: UpdateUserEmailArgs): Promise<UpdateUserCredentialsResult> {
|
||||
try {
|
||||
await this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(UsersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(UsersSchema.id, args.userId));
|
||||
await tx
|
||||
.update(OrganizationMembersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
|
||||
await tx
|
||||
.update(CollaborationUsersSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
|
||||
await tx
|
||||
.update(SsoIdentitiesSchema)
|
||||
.set({ email: args.email })
|
||||
.where(eq(SsoIdentitiesSchema.userId, args.userId));
|
||||
});
|
||||
return 'ok';
|
||||
} catch (error) {
|
||||
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
|
||||
if (pgCode === '23505') {
|
||||
return 'conflict';
|
||||
}
|
||||
$logger.error(error, `Can not update email for user ${args.userId}`);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserPassword(password: string, userId: number): Promise<boolean> {
|
||||
try {
|
||||
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router, type NextFunction, type Request, type Response } from 'express'
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import AuthController from './AuthController';
|
||||
import { IsLoggedIn } from './middlewares/is-logged-in';
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth';
|
||||
import { RequireAnyLoginMethod, RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
|
||||
import passport from './strategies/passport-login';
|
||||
import { ExternalProviderScope } from './strategies/external-auth.types';
|
||||
export default class AuthRoutes implements Routable {
|
||||
@@ -19,13 +21,20 @@ export default class AuthRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('/send-login-code', this.authController.sendLoginCode);
|
||||
this.router.post('/login-by-code', this.authController.loginByCode);
|
||||
this.router.post('/login', this.authController.login);
|
||||
this.router.get('/login-options', this.authController.getLoginOptions);
|
||||
this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode);
|
||||
// Shared one-time-code redemption: magic-link emails, SSO callbacks and social
|
||||
// OAuth callbacks all complete the login through this endpoint
|
||||
this.router.post('/login-by-code', [RequireAnyLoginMethod(['magic-link', 'sso', 'social'])], this.authController.loginByCode);
|
||||
this.router.post('/login', [RequireLoginMethod('password')], this.authController.login);
|
||||
this.router.post('/registration', this.authController.registration);
|
||||
this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail);
|
||||
this.router.post('/email/recovery', this.authController.remindPassword);
|
||||
this.router.post('/password/reset', this.authController.changeRemindedPassword);
|
||||
this.router.post('/email/recovery', [RequireLoginMethod('password')], this.authController.remindPassword);
|
||||
this.router.post('/password/reset', [RequireLoginMethod('password')], this.authController.changeRemindedPassword);
|
||||
this.router.get('/password/change/mode', [IsLoggedIn], this.authController.getPasswordChangeMode);
|
||||
this.router.post('/password/change/code', [IsLoggedIn, RejectApiTokenAuth], this.authController.sendPasswordChangeCode);
|
||||
this.router.post('/password/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeOwnPassword);
|
||||
this.router.post('/credentials/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeDefaultUserCredentials);
|
||||
this.router.post('/logout', [IsLoggedIn], this.authController.logout);
|
||||
this.router.post('/refresh/token', this.authController.refreshTokens);
|
||||
this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode);
|
||||
@@ -33,6 +42,7 @@ export default class AuthRoutes implements Routable {
|
||||
|
||||
this.router.get(
|
||||
'/provider/:providerName',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName],
|
||||
session: false,
|
||||
@@ -43,6 +53,7 @@ export default class AuthRoutes implements Routable {
|
||||
);
|
||||
this.router.get(
|
||||
'/provider/:providerName/callback',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
@@ -51,6 +62,7 @@ export default class AuthRoutes implements Routable {
|
||||
|
||||
this.router.post(
|
||||
'/provider/:providerName/callback',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LoginMethod } from '../../types/auth.types';
|
||||
|
||||
export class LoginMethods {
|
||||
static readonly ALL: LoginMethod[] = ['magic-link', 'password', 'sso', 'social'];
|
||||
|
||||
static enabled(): Set<LoginMethod> {
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) {
|
||||
return new Set(LoginMethods.ALL);
|
||||
}
|
||||
return new Set(LoginMethods.parse(raw).valid);
|
||||
}
|
||||
|
||||
static isEnabled(method: LoginMethod): boolean {
|
||||
return LoginMethods.enabled().has(method);
|
||||
}
|
||||
|
||||
static publicRegistrationAllowed(): boolean {
|
||||
return process.env.ALLOW_PUBLIC_REGISTRATION?.trim().toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const registrationRaw = process.env.ALLOW_PUBLIC_REGISTRATION;
|
||||
if (registrationRaw !== undefined && registrationRaw.trim() !== '') {
|
||||
const normalized = registrationRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(
|
||||
`ALLOW_PUBLIC_REGISTRATION has unrecognized value "${registrationRaw}". Allowed: true, false`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
const { valid, invalid } = LoginMethods.parse(raw);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`AUTH_LOGIN_METHODS contains unknown values: ${invalid.join(', ')}. Allowed: ${LoginMethods.ALL.join(', ')}`
|
||||
);
|
||||
}
|
||||
if (valid.length === 0) {
|
||||
throw new Error('AUTH_LOGIN_METHODS disables every login method — nobody would be able to sign in');
|
||||
}
|
||||
}
|
||||
|
||||
static configuredSocialProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_CALLBACK_URL) {
|
||||
providers.push('google');
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL) {
|
||||
providers.push('github');
|
||||
}
|
||||
if (
|
||||
process.env.APPLE_CLIENT_ID &&
|
||||
process.env.APPLE_TEAM_ID &&
|
||||
process.env.APPLE_KEY_ID &&
|
||||
process.env.APPLE_CALLBACK_URL &&
|
||||
process.env.APPLE_KEY_LOCATION
|
||||
) {
|
||||
providers.push('apple');
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
static availableSocialProviders(): string[] {
|
||||
return LoginMethods.isEnabled('social') ? LoginMethods.configuredSocialProviders() : [];
|
||||
}
|
||||
|
||||
private static parse(raw: string): { valid: LoginMethod[]; invalid: string[] } {
|
||||
const values = raw
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const valid = values.filter((value): value is LoginMethod => (LoginMethods.ALL as string[]).includes(value));
|
||||
const invalid = values.filter((value) => !(LoginMethods.ALL as string[]).includes(value));
|
||||
return { valid, invalid };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { LoginMethod } from '../../../types/auth.types';
|
||||
import { LoginMethods } from '../LoginMethods';
|
||||
|
||||
export const RequireLoginMethod = (method: LoginMethod) => {
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!LoginMethods.isEnabled(method)) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const RequireAnyLoginMethod = (methods: LoginMethod[]) => {
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!methods.some((method) => LoginMethods.isEnabled(method))) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const RequireSocialProvider = (req: Request, res: Response, next: NextFunction) => {
|
||||
const providerName = String(req.params.providerName || '').toLowerCase();
|
||||
if (!LoginMethods.availableSocialProviders().includes(providerName)) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -16,8 +16,7 @@ export function initAppleStrategy() {
|
||||
!process.env.APPLE_KEY_ID ||
|
||||
!process.env.APPLE_CALLBACK_URL ||
|
||||
!process.env.APPLE_KEY_LOCATION) {
|
||||
$logger.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
|
||||
console.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
|
||||
$logger.debug("Apple login is not configured (APPLE_CLIENT_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_CALLBACK_URL / APPLE_KEY_LOCATION) — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { VerifyCallback } from "passport-google-oauth20";
|
||||
|
||||
export function initGithubStrategy() {
|
||||
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET || !process.env.GITHUB_CALLBACK_URL) {
|
||||
$logger.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
|
||||
console.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
|
||||
$logger.debug("GitHub login is not configured (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / GITHUB_CALLBACK_URL) — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { ExternalAuthUser } from "./external-auth.types";
|
||||
|
||||
export function initGoogleStrategy() {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_CALLBACK_URL) {
|
||||
$logger.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
|
||||
console.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
|
||||
$logger.debug("Google login is not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_CALLBACK_URL) — skipping");
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
|
||||
+16
@@ -1,5 +1,8 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
|
||||
export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = req.body.goalId ? req.body.goalId : req.params.goalId;
|
||||
@@ -19,5 +22,18 @@ export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, r
|
||||
return next();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanFetchRolesPermissionsCollaborationRoles middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './collaboration.types';
|
||||
|
||||
export class CollaborationController {
|
||||
/** @deprecated */
|
||||
fetchAllUsers = async (req: Request, res: Response) => {
|
||||
const users = await req.appUser.collaborationManager.fetchAllUsers();
|
||||
return res.tvJson(users);
|
||||
@@ -82,19 +83,30 @@ export class CollaborationController {
|
||||
return res.status(400).send(output.summary);
|
||||
}
|
||||
|
||||
const user = await req.appUser.collaborationManager.addUserNew(output);
|
||||
const result = await req.appUser.collaborationManager.addUserNew(output);
|
||||
|
||||
if (user) {
|
||||
// created=false means the person was already in the goal — re-POSTing must not re-notify
|
||||
if (result?.created) {
|
||||
eventBus.emit('collaboration.userAdded', {
|
||||
goalId: output.goalId,
|
||||
email: output.email.toLowerCase(),
|
||||
initiatorId: req.appUser.getUserData()!.id,
|
||||
locale: this.resolveLocale(req),
|
||||
});
|
||||
}
|
||||
|
||||
return res.tvJson(user ?? null);
|
||||
return res.tvJson(result?.user ?? null);
|
||||
};
|
||||
|
||||
// The invitee has no stored locale (often no account yet), so localize by the inviter's browser language
|
||||
private resolveLocale(req: Request): 'en' | 'ru' {
|
||||
const acceptLanguage = req.headers['accept-language'];
|
||||
if (!acceptLanguage) return 'en';
|
||||
|
||||
const languages = acceptLanguage.split(',').map((lang) => lang.split(';')[0].trim().toLowerCase());
|
||||
return languages.some((lang) => lang === 'ru' || lang.startsWith('ru-')) ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
deleteUserNew = async (req: Request, res: Response) => {
|
||||
const output = CollaborationArkTypeDeleteUser(req.body);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { CollaborationRepository } from './CollaborationRepository';
|
||||
import type {
|
||||
CollaborationAddUserResult,
|
||||
CollaborationArgAddUser,
|
||||
CollaborationArgDeleteUser,
|
||||
CollaborationArgToggleUserRoles,
|
||||
@@ -24,6 +25,7 @@ export class CollaborationManager {
|
||||
this.repository = new CollaborationRepository();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchAllUsers(): Promise<CollaborationUserWithRoles[] | false> {
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
|
||||
|
||||
@@ -69,6 +71,7 @@ export class CollaborationManager {
|
||||
return Object.values(resultMap);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoal(args: FetchGoalUsersArg): Promise<CollaborationUserWithRoles[] | false> {
|
||||
const users = await this.repository.fetchUsersForGoal(args.goalId);
|
||||
|
||||
@@ -103,6 +106,7 @@ export class CollaborationManager {
|
||||
return Object.values(resultMap);
|
||||
}
|
||||
|
||||
/** @deprecated*/
|
||||
async toggleUserRoles(args: ToggleUserRolesArg): Promise<number[] | false> {
|
||||
return await this.repository.updateUserRoles(args.userId, args.roles);
|
||||
}
|
||||
@@ -120,7 +124,7 @@ export class CollaborationManager {
|
||||
return await this.repository.deleteUser(args);
|
||||
}
|
||||
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationAddUserResult | null> {
|
||||
const email = args.email.toLowerCase();
|
||||
|
||||
const goal = await this.user.goalsManager.goalsRepository.findGoalById(args.goalId);
|
||||
@@ -131,19 +135,22 @@ export class CollaborationManager {
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.repository.addUserForCollaborationNew({
|
||||
const result = await this.repository.addUserForCollaborationNew({
|
||||
...args,
|
||||
email,
|
||||
});
|
||||
if (!user) return null;
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
goalId: args.goalId,
|
||||
goal_id: args.goalId,
|
||||
invitation_date: user.invitationDate,
|
||||
roles: [],
|
||||
goalOwner: false,
|
||||
user: {
|
||||
...result.user,
|
||||
goalId: args.goalId,
|
||||
goal_id: args.goalId,
|
||||
invitation_date: result.user.invitationDate,
|
||||
roles: [],
|
||||
goalOwner: false,
|
||||
},
|
||||
created: result.created,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +184,7 @@ export class CollaborationManager {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resultMap: Record<string, CollaborationUserWithRoles> = {};
|
||||
|
||||
users.forEach((item) => {
|
||||
@@ -210,7 +217,7 @@ export class CollaborationManager {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resultMap: Record<string, CollaborationUserWithRoles> = {};
|
||||
|
||||
users.forEach((item) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq, exists, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
CollaborationRolesSchema,
|
||||
CollaborationUsersSchema,
|
||||
type CollaborationUsersSchemaTypeForSelect,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
@@ -10,6 +11,7 @@ import { $logger } from '../../modules/logget';
|
||||
import { logError } from '../../utils/api';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type {
|
||||
CollaborationAddUserRepoResult,
|
||||
CollaborationArgAddUser,
|
||||
CollaborationArgDeleteUser,
|
||||
CollaborationArgToggleUserRoles,
|
||||
@@ -23,6 +25,7 @@ export class CollaborationRepository {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchAllUsers(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
@@ -34,6 +37,10 @@ export class CollaborationRepository {
|
||||
FROM collaboration.users u
|
||||
left join collaboration.users_to_goals utg on u.id = utg.user_id
|
||||
LEFT JOIN collaboration.users_to_roles utr ON u.id = utr.user_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM collaboration.roles r
|
||||
WHERE r.id = utr.role_id AND r.goal_id = utg.goal_id
|
||||
)
|
||||
WHERE utg.goal_id IN (${placeholders})
|
||||
`;
|
||||
|
||||
@@ -74,7 +81,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;
|
||||
@@ -91,7 +98,8 @@ export class CollaborationRepository {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoal(goalId: number): Promise<FetchUsersForGoal[] | false> {
|
||||
const query = `
|
||||
SELECT u.*, u.invitation_date::text, utr.role_id, utg.goal_id
|
||||
@@ -111,6 +119,7 @@ export class CollaborationRepository {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoals(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
@@ -145,6 +154,7 @@ export class CollaborationRepository {
|
||||
return !!(result.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async updateUserRoles(userId: number, roles: number[]): Promise<number[] | false> {
|
||||
const deleteQuery = `DELETE FROM collaboration.users_to_roles WHERE user_id = $1`;
|
||||
let i = 1;
|
||||
@@ -197,8 +207,8 @@ export class CollaborationRepository {
|
||||
|
||||
async addUserForCollaborationNew(
|
||||
args: CollaborationArgAddUser
|
||||
): Promise<CollaborationUsersSchemaTypeForSelect | null> {
|
||||
const user = await callWithCatch(() =>
|
||||
): Promise<CollaborationAddUserRepoResult | null> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.transaction(async (tx) => {
|
||||
let userId: number;
|
||||
let user: CollaborationUsersSchemaTypeForSelect;
|
||||
@@ -217,18 +227,14 @@ export class CollaborationRepository {
|
||||
user = userTransaction;
|
||||
}
|
||||
|
||||
await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
const linked = await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
userId: userId,
|
||||
goalId: args.goalId,
|
||||
});
|
||||
}).onConflictDoNothing().returning();
|
||||
|
||||
return user;
|
||||
return { user, created: linked.length > 0 };
|
||||
})
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUserNew(args: CollaborationArgDeleteUser) {
|
||||
@@ -250,13 +256,29 @@ export class CollaborationRepository {
|
||||
async toggleUserRolesNew(args: CollaborationArgToggleUserRoles): Promise<number[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CollaborationUsersToRolesSchema)
|
||||
.where(eq(CollaborationUsersToRolesSchema.userId, args.userId));
|
||||
if (args.roles.length > 0) {
|
||||
|
||||
const goalRoles = await tx
|
||||
.select({ id: CollaborationRolesSchema.id })
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(eq(CollaborationRolesSchema.goalId, args.goalId));
|
||||
const goalRoleIds = goalRoles.map((role) => role.id);
|
||||
|
||||
if (goalRoleIds.length > 0) {
|
||||
await tx
|
||||
.delete(CollaborationUsersToRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationUsersToRolesSchema.userId, args.userId),
|
||||
inArray(CollaborationUsersToRolesSchema.roleId, goalRoleIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rolesToAssign = args.roles.filter((roleId) => goalRoleIds.includes(roleId));
|
||||
if (rolesToAssign.length > 0) {
|
||||
return await tx
|
||||
.insert(CollaborationUsersToRolesSchema)
|
||||
.values(args.roles.map((roleId) => ({ userId: args.userId, roleId })))
|
||||
.values(rolesToAssign.map((roleId) => ({ userId: args.userId, roleId })))
|
||||
.returning();
|
||||
}
|
||||
return [];
|
||||
@@ -287,7 +309,20 @@ export class CollaborationRepository {
|
||||
)
|
||||
.leftJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
|
||||
and(
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(inArray(CollaborationUsersToGoalsSchema.goalId, goalIds))
|
||||
);
|
||||
@@ -311,7 +346,20 @@ export class CollaborationRepository {
|
||||
)
|
||||
.leftJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
|
||||
and(
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { and, count, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
import { GoalsSchema, InviteEmailsSchema, OrganizationsSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import { Email } from '../../core/Email';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { escapeHtml, parsePositiveInt } from '../../utils/helpers';
|
||||
import InviteEmailTemplateEn from './mail/invite-en';
|
||||
import InviteEmailTemplateRu from './mail/invite-ru';
|
||||
import type { InviteEmailRateLimitArgs, InviteEmailSendArgs } from './collaboration.server.types';
|
||||
|
||||
const DEFAULT_HOURLY_LIMIT = 30;
|
||||
|
||||
export class InviteEmailDispatcher implements Dispatcher {
|
||||
static enabled(): boolean {
|
||||
return process.env.INVITE_EMAIL_ENABLED?.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
static hourlyLimit(): number {
|
||||
return parsePositiveInt(process.env.INVITE_EMAIL_HOURLY_LIMIT) ?? DEFAULT_HOURLY_LIMIT;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const enabledRaw = process.env.INVITE_EMAIL_ENABLED;
|
||||
if (enabledRaw !== undefined && enabledRaw.trim() !== '') {
|
||||
const normalized = enabledRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(`INVITE_EMAIL_ENABLED has unrecognized value "${enabledRaw}". Allowed: true, false`);
|
||||
}
|
||||
}
|
||||
|
||||
const limitRaw = process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
if (limitRaw !== undefined && limitRaw.trim() !== '' && parsePositiveInt(limitRaw) === null) {
|
||||
throw new Error(
|
||||
`INVITE_EMAIL_HOURLY_LIMIT has unrecognized value "${limitRaw}". Expected a positive integer`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
register(): void {
|
||||
eventBus.on('collaboration.userAdded', (data) => this.onUserAdded(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {}
|
||||
|
||||
private async onUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
|
||||
if (!InviteEmailDispatcher.enabled() || !process.env.SMTP_HOST) return;
|
||||
|
||||
const db = Database.getInstance();
|
||||
|
||||
const [goal] = await db.dbDrizzle
|
||||
.select({ name: GoalsSchema.name, organizationId: GoalsSchema.organizationId })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, data.goalId))
|
||||
.limit(1);
|
||||
if (!goal) return;
|
||||
|
||||
const [inviter] = await db.dbDrizzle
|
||||
.select({ login: UsersSchema.login })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, data.initiatorId))
|
||||
.limit(1);
|
||||
if (!inviter) return;
|
||||
|
||||
const allowed = await this.passesRateLimit({
|
||||
initiatorId: data.initiatorId,
|
||||
email: data.email,
|
||||
goalId: data.goalId,
|
||||
});
|
||||
if (!allowed) return;
|
||||
|
||||
const link = await this.buildGoalLink(data.goalId, goal.organizationId);
|
||||
if (!link) {
|
||||
$logger.warn('APP_URL is not set — skipping invite email');
|
||||
return;
|
||||
}
|
||||
|
||||
await db.dbDrizzle.insert(InviteEmailsSchema).values({
|
||||
initiatorId: data.initiatorId,
|
||||
email: data.email,
|
||||
goalId: data.goalId,
|
||||
});
|
||||
|
||||
const fallbackName = data.locale === 'ru' ? 'Пользователь TaskView' : 'A TaskView user';
|
||||
|
||||
await this.sendInviteEmail({
|
||||
email: data.email,
|
||||
inviterName: this.truncate(inviter.login?.trim() || fallbackName),
|
||||
goalName: this.truncate(goal.name || ''),
|
||||
link,
|
||||
locale: data.locale,
|
||||
});
|
||||
}
|
||||
|
||||
// Two rules: a 24h cooldown per (goal, recipient) — closes the delete/re-add resend loop —
|
||||
// and an hourly cap per initiator against using the instance as a mail relay.
|
||||
// Rows older than the cooldown window are pruned first, keeping the table tiny.
|
||||
private async passesRateLimit(args: InviteEmailRateLimitArgs): Promise<boolean> {
|
||||
const db = Database.getInstance();
|
||||
|
||||
await db.dbDrizzle
|
||||
.delete(InviteEmailsSchema)
|
||||
.where(lt(InviteEmailsSchema.sentAt, sql`now() - interval '24 hours'`));
|
||||
|
||||
const [cooldown] = await db.dbDrizzle
|
||||
.select({ id: InviteEmailsSchema.id })
|
||||
.from(InviteEmailsSchema)
|
||||
.where(and(eq(InviteEmailsSchema.goalId, args.goalId), eq(InviteEmailsSchema.email, args.email)))
|
||||
.limit(1);
|
||||
if (cooldown) return false;
|
||||
|
||||
const [hourly] = await db.dbDrizzle
|
||||
.select({ count: count() })
|
||||
.from(InviteEmailsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(InviteEmailsSchema.initiatorId, args.initiatorId),
|
||||
gte(InviteEmailsSchema.sentAt, sql`now() - interval '1 hour'`)
|
||||
)
|
||||
);
|
||||
if ((hourly?.count ?? 0) >= InviteEmailDispatcher.hourlyLimit()) {
|
||||
$logger.warn(
|
||||
{ initiatorId: args.initiatorId, goalId: args.goalId },
|
||||
'Invite email hourly limit reached — skipping send'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendInviteEmail(args: InviteEmailSendArgs): Promise<void> {
|
||||
const template = args.locale === 'ru' ? InviteEmailTemplateRu : InviteEmailTemplateEn;
|
||||
const subject =
|
||||
args.locale === 'ru'
|
||||
? `${args.inviterName} приглашает вас в проект «${args.goalName}» в TaskView`
|
||||
: `${args.inviterName} invited you to "${args.goalName}" on TaskView`;
|
||||
const text =
|
||||
args.locale === 'ru'
|
||||
? `${args.inviterName} приглашает вас присоединиться к проекту «${args.goalName}» в TaskView.\n\nОткрыть проект: ${args.link}`
|
||||
: `${args.inviterName} has invited you to join the project "${args.goalName}" on TaskView.\n\nOpen the project: ${args.link}`;
|
||||
|
||||
// Single-pass replace with a function: no re-substitution of placeholders inside
|
||||
// inserted values, and no special treatment of $-patterns in the replacement
|
||||
const values: Record<string, string> = {
|
||||
inviter: args.inviterName,
|
||||
project: args.goalName,
|
||||
link: args.link,
|
||||
};
|
||||
const html = template.replace(/\{(inviter|project|link)\}/g, (_, key: string) => escapeHtml(values[key]));
|
||||
|
||||
await Email.send({
|
||||
text,
|
||||
subject,
|
||||
to: args.email,
|
||||
from: process.env.SMTP_FROM_EMAIL as string,
|
||||
attachment: [{ data: html, alternative: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Frontend project route is /:orgSlug/:projectId; goals without an organization fall back to the app root
|
||||
private async buildGoalLink(goalId: number, organizationId: number | null): Promise<string | null> {
|
||||
const appUrl = (process.env.APP_URL ?? '').replace(/\/+$/, '');
|
||||
if (!appUrl) return null;
|
||||
if (!organizationId) return appUrl;
|
||||
|
||||
const db = Database.getInstance();
|
||||
const [org] = await db.dbDrizzle
|
||||
.select({ slug: OrganizationsSchema.slug })
|
||||
.from(OrganizationsSchema)
|
||||
.where(eq(OrganizationsSchema.id, organizationId))
|
||||
.limit(1);
|
||||
if (!org?.slug) return appUrl;
|
||||
|
||||
return `${appUrl}/${encodeURIComponent(org.slug)}/${goalId}`;
|
||||
}
|
||||
|
||||
private truncate(value: string): string {
|
||||
const max = 80;
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Email } from '../../../core/Email';
|
||||
import type { AppEvents } from '../../../core/EventBus';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { InviteEmailDispatcher } from '../InviteEmailDispatcher';
|
||||
|
||||
vi.mock('../../../core/Email', () => ({
|
||||
Email: {
|
||||
send: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../modules/db', () => ({
|
||||
Database: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Each select() call consumes the next result; the returned query is both awaitable
|
||||
// (count query) and .limit()-able (lookups), matching the Drizzle chains in the dispatcher
|
||||
function mockDb(selectResults: unknown[][]) {
|
||||
const queue = [...selectResults];
|
||||
const insertValues = vi.fn(async () => undefined);
|
||||
const dbDrizzle = {
|
||||
select: vi.fn(() => {
|
||||
const rows = queue.shift() ?? [];
|
||||
const query = {
|
||||
limit: async () => rows,
|
||||
then: (resolve: (rows: unknown[]) => void, reject: (err: unknown) => void) =>
|
||||
Promise.resolve(rows).then(resolve, reject),
|
||||
};
|
||||
return { from: () => ({ where: () => query }) };
|
||||
}),
|
||||
delete: vi.fn(() => ({ where: async () => undefined })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
};
|
||||
vi.mocked(Database.getInstance).mockReturnValue({ dbDrizzle } as any);
|
||||
return { dbDrizzle, insertValues };
|
||||
}
|
||||
|
||||
const goalRow = { name: 'Marketing', organizationId: 3 };
|
||||
const inviterRow = { login: 'Alice' };
|
||||
const noCooldown: unknown[] = [];
|
||||
const underLimit = [{ count: 0 }];
|
||||
const orgRow = [{ slug: 'acme' }];
|
||||
|
||||
const inviteEvent: AppEvents['collaboration.userAdded'] = {
|
||||
goalId: 42,
|
||||
email: 'invitee@example.com',
|
||||
initiatorId: 7,
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
describe('InviteEmailDispatcher', () => {
|
||||
const dispatcher = new InviteEmailDispatcher();
|
||||
const onUserAdded = (data: typeof inviteEvent) => (dispatcher as any).onUserAdded(data);
|
||||
const sentHtml = () => (vi.mocked(Email.send).mock.calls[0][0] as any).attachment[0].data as string;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'true';
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM_EMAIL = 'noreply@test';
|
||||
process.env.APP_URL = 'http://localhost:3000';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not send when the flag is off', async () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
mockDb([]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send when the flag is unset', async () => {
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
mockDb([]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a localized email with a project deep link and records the send', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, underLimit, orgRow]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).toHaveBeenCalledTimes(1);
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.to).toBe('invitee@example.com');
|
||||
expect(message.from).toBe('noreply@test');
|
||||
expect(message.subject).toBe('Alice invited you to "Marketing" on TaskView');
|
||||
expect(message.text).toContain('http://localhost:3000/acme/42');
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain("You've been invited to a project");
|
||||
expect(html).toContain('Alice');
|
||||
expect(html).toContain('href="http://localhost:3000/acme/42"');
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
initiatorId: 7,
|
||||
email: 'invitee@example.com',
|
||||
goalId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the Russian template for the ru locale', async () => {
|
||||
mockDb([[{ name: 'Маркетинг', organizationId: null }], [{ login: 'Алиса' }], noCooldown, underLimit]);
|
||||
|
||||
await onUserAdded({ ...inviteEvent, locale: 'ru' });
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toBe('Алиса приглашает вас в проект «Маркетинг» в TaskView');
|
||||
expect(sentHtml()).toContain('Вас пригласили в проект');
|
||||
expect(sentHtml()).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('skips the send during the per-recipient cooldown', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], [{ id: 1 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the send when the hourly limit is reached', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 30 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects a custom INVITE_EMAIL_HOURLY_LIMIT', async () => {
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '2';
|
||||
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 2 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
|
||||
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 1 }], orgRow]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('escapes user-controlled values in the html', async () => {
|
||||
mockDb([
|
||||
[{ name: '<img src=x onerror=alert(1)>', organizationId: null }],
|
||||
[{ login: 'Bob & "Co"' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Bob & "Co"');
|
||||
});
|
||||
|
||||
it('is immune to $-patterns and placeholder strings in user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'Project $` name', organizationId: null }],
|
||||
[{ login: '{link}' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain('Project $` name');
|
||||
expect(html).toContain('{link}');
|
||||
expect(html).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('truncates overlong user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'p'.repeat(200), organizationId: null }],
|
||||
[{ login: 'i'.repeat(200) }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toContain(`"${'p'.repeat(80)}…"`);
|
||||
expect(message.text).toContain(`${'i'.repeat(80)}… has invited`);
|
||||
});
|
||||
|
||||
it('does not send when the goal no longer exists', async () => {
|
||||
mockDb([[]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateOnStartup rejects unrecognized values', () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'ture';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_ENABLED');
|
||||
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = 'abc';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '0';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '10';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import type { CollaborationUsersSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export const CollaborationArkTypeAddUser = type({
|
||||
goalId: 'number',
|
||||
@@ -97,3 +98,30 @@ export const CollaborationArkTypeToggleRolePermission = type({
|
||||
});
|
||||
|
||||
export type CollaborationArgToggleRolePermission = typeof CollaborationArkTypeToggleRolePermission.infer;
|
||||
|
||||
// created=false means the person was already a collaborator of the goal — no invitation happened
|
||||
export type CollaborationAddUserRepoResult = {
|
||||
user: CollaborationUsersSchemaTypeForSelect;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type CollaborationAddUserResult = {
|
||||
user: CollaborationUserWithRoles;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type InviteEmailLocale = 'en' | 'ru';
|
||||
|
||||
export type InviteEmailSendArgs = {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
goalName: string;
|
||||
link: string;
|
||||
locale: InviteEmailLocale;
|
||||
};
|
||||
|
||||
export type InviteEmailRateLimitArgs = {
|
||||
initiatorId: number;
|
||||
email: string;
|
||||
goalId: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Project invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited to a project</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> has invited you to join the project<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Open project</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">If the button doesn't work, copy this link into your browser:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">You received this email because someone invited you to a project on TaskView. If you weren't expecting it, you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Приглашение в проект</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Вас пригласили в проект</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> приглашает вас присоединиться к проекту<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Открыть проект</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -217,7 +237,7 @@ export default class GoalsManager {
|
||||
|
||||
await this.user.collaborationManager.repository.toggleUserRolesNew({
|
||||
goalId,
|
||||
userId: collabUser.id,
|
||||
userId: collabUser.user.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError } from '../../utils/api';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { verifyGitHubWebhookSignature, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { verifyGitLabWebhookToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { verifyGiteaWebhookSignature, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
|
||||
|
||||
export default class IntegrationsController {
|
||||
@@ -47,23 +50,29 @@ export default class IntegrationsController {
|
||||
|
||||
initiateOAuth = async (req: Request, res: Response) => {
|
||||
try {
|
||||
integrationsDebugLog({ step: 'initiate:start', data: { provider: req.params.provider, projectId: req.query.projectId, hasToken: !!req.query.token } });
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'token is required' });
|
||||
return res.status(401).send('token is required');
|
||||
}
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (!userPayload?.userData?.id) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'invalid token' });
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const provider = req.params.provider;
|
||||
const projectId = Number(req.query.projectId);
|
||||
if (!projectId || isNaN(projectId)) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'projectId is required' });
|
||||
return res.status(400).send('projectId is required');
|
||||
}
|
||||
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
|
||||
integrationsDebugLog({ step: 'initiate:redirect', data: { userId: userPayload.userData.id, url } });
|
||||
return res.redirect(url);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({ step: 'initiate:error', data: { message: err?.message, stack: err?.stack } });
|
||||
logError(err);
|
||||
return res.status(500).send('Failed to initiate OAuth');
|
||||
}
|
||||
@@ -74,15 +83,36 @@ export default class IntegrationsController {
|
||||
const provider = req.params.provider;
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
integrationsDebugLog({ step: 'callback:start', data: { provider, hasCode: !!code, hasState: !!state, queryKeys: Object.keys(req.query) } });
|
||||
|
||||
if (!code || !state) {
|
||||
integrationsDebugLog({ step: 'callback:reject', data: 'missing code or state' });
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
|
||||
const { projectId, userLogin } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
return res.redirect(`${process.env.APP_URL}/${userLogin}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
const { projectId, orgSlug } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
integrationsDebugLog({ step: 'callback:success', data: { projectId, orgSlug } });
|
||||
return res.redirect(`${process.env.APP_URL}/${orgSlug}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({
|
||||
step: 'callback:error',
|
||||
data: {
|
||||
message: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
});
|
||||
$logger.error(
|
||||
{
|
||||
provider: req.params.provider,
|
||||
errorMessage: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
'[integrations] OAuth callback failed',
|
||||
);
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
};
|
||||
@@ -210,6 +240,91 @@ export default class IntegrationsController {
|
||||
}
|
||||
};
|
||||
|
||||
handleGiteaWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const signature = req.headers['x-gitea-signature'] as string;
|
||||
const event = req.headers['x-gitea-event'] as string;
|
||||
|
||||
if (!signature) {
|
||||
return res.status(401).send('Missing signature');
|
||||
}
|
||||
|
||||
if (event !== 'issues') {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
if (!repoFullName) {
|
||||
return res.status(400).send('Missing repository');
|
||||
}
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integrations = await repo.fetchAllActiveByRepoFullName(repoFullName);
|
||||
if (integrations.length === 0) {
|
||||
return res.status(404).send('Integration not found');
|
||||
}
|
||||
|
||||
// Verify signature with the first integration that has a webhook secret
|
||||
const withSecret = integrations.find((i) => i.webhookSecretEncrypted);
|
||||
if (!withSecret) {
|
||||
return res.status(401).send('No webhook secret');
|
||||
}
|
||||
const secret = decrypt(withSecret.webhookSecretEncrypted!);
|
||||
const rawBody = (req as any).rawBody as Buffer;
|
||||
if (!rawBody || !verifyGiteaWebhookSignature({ rawBody, signature, secret })) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
const action = req.body.action as string;
|
||||
const issue = req.body.issue;
|
||||
if (!issue) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const issueNumber = issue.number as number;
|
||||
const issueTitle = issue.title as string;
|
||||
const issueBody = (issue.body as string) || null;
|
||||
|
||||
for (const integration of integrations) {
|
||||
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueNumber);
|
||||
|
||||
if (action === 'opened') {
|
||||
if (!mapping) {
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueNumber,
|
||||
'open',
|
||||
issueBody,
|
||||
false,
|
||||
`${GITEA_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'edited') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueBody);
|
||||
}
|
||||
} else if (action === 'closed') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, true);
|
||||
await repo.updateMappingState(mapping.id, 'closed');
|
||||
}
|
||||
} else if (action === 'reopened') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, false);
|
||||
await repo.updateMappingState(mapping.id, 'open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send('OK');
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Webhook processing failed');
|
||||
}
|
||||
};
|
||||
|
||||
handleGitLabWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.headers['x-gitlab-token'] as string;
|
||||
|
||||
@@ -8,10 +8,12 @@ import { $logger } from '../../modules/logget';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
import type { IntegrationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { getGiteaOAuthUrl, exchangeGiteaCode, fetchGiteaRepos, fetchGiteaIssues, createGiteaWebhook, updateGiteaIssueState, refreshGiteaToken, verifyGiteaToken, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
|
||||
export class IntegrationsManager {
|
||||
public readonly repository: IntegrationsRepository;
|
||||
@@ -55,13 +57,16 @@ export class IntegrationsManager {
|
||||
return getGitHubOAuthUrl(state);
|
||||
} else if (provider === 'gitlab') {
|
||||
return getGitLabOAuthUrl(state);
|
||||
} else if (provider === 'gitea') {
|
||||
return getGiteaOAuthUrl(state);
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; orgSlug: string }> {
|
||||
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
|
||||
integrationsDebugLog({ step: 'callback:state-verified', data: { userId: payload.userId, projectId: payload.projectId, provider: payload.provider } });
|
||||
|
||||
if (payload.provider !== provider) {
|
||||
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
|
||||
@@ -69,6 +74,7 @@ export class IntegrationsManager {
|
||||
}
|
||||
|
||||
const userLogin = await this.repository.fetchUserLogin(payload.userId);
|
||||
integrationsDebugLog({ step: 'callback:user-fetched', data: { userLogin } });
|
||||
if (!userLogin) {
|
||||
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
|
||||
throw new Error('User not found');
|
||||
@@ -84,19 +90,35 @@ export class IntegrationsManager {
|
||||
const tokens = await exchangeGitLabCode(code);
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = encrypt(tokens.refreshToken);
|
||||
} else if (provider === 'gitea') {
|
||||
const tokens = await exchangeGiteaCode(code);
|
||||
integrationsDebugLog({ step: 'callback:token-exchanged', data: { hasAccessToken: !!tokens.accessToken, hasRefreshToken: !!tokens.refreshToken } });
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = tokens.refreshToken ? encrypt(tokens.refreshToken) : null;
|
||||
} else {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:tokens-encrypted' });
|
||||
|
||||
await this.repository.createWithToken(
|
||||
provider as 'github' | 'gitlab',
|
||||
const created = await this.repository.createWithToken(
|
||||
provider as IntegrationProvider,
|
||||
payload.projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted,
|
||||
);
|
||||
if (!created) {
|
||||
integrationsDebugLog({ step: 'callback:db-insert-failed' });
|
||||
throw new Error('Failed to store integration record');
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:integration-created', data: { integrationId: created.id } });
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, userLogin };
|
||||
// The app routes are /:orgSlug/:projectId/... — redirect must use the slug
|
||||
// of the project's organization, falling back to the user login for legacy
|
||||
// projects without an organization.
|
||||
const orgSlug = await this.repository.fetchProjectOrgSlug(payload.projectId) ?? userLogin;
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, orgSlug }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, orgSlug };
|
||||
}
|
||||
|
||||
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
|
||||
@@ -126,6 +148,16 @@ export class IntegrationsManager {
|
||||
description: r.description,
|
||||
url: r.web_url,
|
||||
}));
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const repos = await fetchGiteaRepos(accessToken);
|
||||
return repos.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
name: r.name,
|
||||
isPrivate: r.private,
|
||||
description: r.description,
|
||||
url: r.html_url,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -174,6 +206,14 @@ export class IntegrationsManager {
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const result = await createGiteaWebhook({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
webhookUrl,
|
||||
secret: webhookSecret,
|
||||
});
|
||||
webhookId = String(result.id);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -197,12 +237,11 @@ export class IntegrationsManager {
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
const issueUrlPrefix = this.getIssueUrlPrefix(integration);
|
||||
|
||||
// Backfill sourceUrl for existing tasks that don't have it yet
|
||||
if (existingMappings.length > 0) {
|
||||
const baseUrl = integration.provider === 'github' ? GITHUB_BASE_URL : GITLAB_BASE_URL;
|
||||
const issuePath = integration.provider === 'gitlab' ? '/-/issues/' : '/issues/';
|
||||
const prefix = `${baseUrl}/${integration.repoFullName}${issuePath}`;
|
||||
await this.repository.backfillSourceUrls(integrationId, prefix).catch(logError);
|
||||
await this.repository.backfillSourceUrls(integrationId, issueUrlPrefix).catch(logError);
|
||||
}
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
|
||||
@@ -263,6 +302,34 @@ export class IntegrationsManager {
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const issues = await fetchGiteaIssues({ accessToken, repoFullName: integration.repoFullName, since });
|
||||
for (const issue of issues) {
|
||||
const existing = mappingsByIssueNumber.get(issue.number);
|
||||
if (existing) {
|
||||
const isClosed = issue.state === 'closed';
|
||||
const targetState = isClosed ? 'closed' : 'open';
|
||||
await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError);
|
||||
if (existing.issueState !== targetState) {
|
||||
await this.repository.updateMappingState(existing.id, targetState).catch(logError);
|
||||
}
|
||||
await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.body ?? null).catch(logError);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${issueUrlPrefix}${issue.number}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
const isClosed = issue.state === 'closed';
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.number,
|
||||
issueState: isClosed ? 'closed' : 'open',
|
||||
note: issue.body ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${issueUrlPrefix}${issue.number}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Issues come newest-first from API.
|
||||
@@ -319,6 +386,13 @@ export class IntegrationsManager {
|
||||
mapping.issueNumber,
|
||||
complete ? 'close' : 'reopen',
|
||||
);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
await updateGiteaIssueState({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
issueNumber: mapping.issueNumber,
|
||||
state: targetState,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.updateMappingState(mapping.id, targetState);
|
||||
@@ -326,41 +400,56 @@ export class IntegrationsManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private getIssueUrlPrefix(integration: IntegrationsSchemaTypeForSelect): string {
|
||||
if (integration.provider === 'gitlab') {
|
||||
return `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/`;
|
||||
}
|
||||
const baseUrl = integration.provider === 'gitea' ? GITEA_BASE_URL : GITHUB_BASE_URL;
|
||||
return `${baseUrl}/${integration.repoFullName}/issues/`;
|
||||
}
|
||||
|
||||
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
|
||||
if (!integration.accessTokenEncrypted) return null;
|
||||
|
||||
const accessToken = decrypt(integration.accessTokenEncrypted);
|
||||
|
||||
if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) {
|
||||
const hasExpiringToken = integration.provider === 'gitlab' || integration.provider === 'gitea';
|
||||
if (!hasExpiringToken || !integration.refreshTokenEncrypted) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Try the current token, refresh on 401
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (integration.provider === 'gitea') {
|
||||
await verifyGiteaToken(accessToken);
|
||||
} else {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
return accessToken;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 401) return accessToken;
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token expired (401), refreshing');
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
try {
|
||||
const refreshToken = decrypt(integration.refreshTokenEncrypted);
|
||||
const tokens = await refreshGitLabToken(refreshToken);
|
||||
const tokens = integration.provider === 'gitea'
|
||||
? await refreshGiteaToken(refreshToken)
|
||||
: await refreshGitLabToken(refreshToken);
|
||||
await this.repository.updateTokens(
|
||||
integration.id,
|
||||
encrypt(tokens.accessToken),
|
||||
encrypt(tokens.refreshToken),
|
||||
);
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token refreshed successfully');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token refreshed successfully');
|
||||
return tokens.accessToken;
|
||||
} catch (err) {
|
||||
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
|
||||
$logger.error({ integrationId: integration.id, provider: integration.provider, err }, '[integrations] token refresh failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
|
||||
import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { GoalsSchema, IntegrationsSchema, IntegrationTaskMapSchema, OrganizationsSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
|
||||
export class IntegrationsRepository {
|
||||
@@ -62,7 +62,7 @@ export class IntegrationsRepository {
|
||||
}
|
||||
|
||||
async createWithToken(
|
||||
provider: 'github' | 'gitlab',
|
||||
provider: IntegrationProvider,
|
||||
projectId: number,
|
||||
accessTokenEncrypted: string,
|
||||
refreshTokenEncrypted?: string | null,
|
||||
@@ -322,6 +322,17 @@ export class IntegrationsRepository {
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchProjectOrgSlug(projectId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
|
||||
.from(GoalsSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(GoalsSchema.id, projectId))
|
||||
);
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].slug;
|
||||
}
|
||||
|
||||
async fetchUserLogin(userId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
|
||||
|
||||
@@ -31,5 +31,6 @@ export default class IntegrationsRoutes implements Routable {
|
||||
this.router.get('/oauth/:provider/callback', this.controller.handleOAuthCallback);
|
||||
this.router.post('/webhook/github', this.controller.handleGitHubWebhook);
|
||||
this.router.post('/webhook/gitlab', this.controller.handleGitLabWebhook);
|
||||
this.router.post('/webhook/gitea', this.controller.handleGiteaWebhook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { appendFileSync } from 'fs';
|
||||
import type { IntegrationsDebugLogEntry } from './types';
|
||||
|
||||
// TEMPORARY debug instrumentation for the integrations OAuth flow.
|
||||
// Remove this file and all integrationsDebugLog() calls once the Gitea
|
||||
// connect issue is resolved.
|
||||
const LOG_PATH = '/private/tmp/claude-501/-Users-nikolaygiman-Programming-HandScreamInc-taskview/1d568266-6fbf-457c-83c2-5c5ca619edf1/scratchpad/integrations-debug.log';
|
||||
|
||||
export function integrationsDebugLog(entry: IntegrationsDebugLogEntry): void {
|
||||
try {
|
||||
appendFileSync(LOG_PATH, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
|
||||
} catch {
|
||||
// debug logging must never break the flow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { GiteaCreateWebhookArgs, GiteaFetchIssuesArgs, GiteaUpdateIssueStateArgs, GiteaVerifyWebhookSignatureArgs } from '../types';
|
||||
|
||||
export const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/+$/, '');
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL || `${GITEA_BASE_URL}/api/v1`;
|
||||
|
||||
export type GiteaRepo = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export type GiteaIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export function getGiteaOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITEA_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITEA_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('Gitea integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `${GITEA_BASE_URL}/login/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGiteaCode(code: string): Promise<{ accessToken: string; refreshToken: string | null }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token?: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: process.env.GITEA_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange Gitea code for token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshGiteaToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to refresh Gitea token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGiteaToken(accessToken: string): Promise<void> {
|
||||
await axios.get(`${GITEA_API_URL}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGiteaRepos(accessToken: string): Promise<GiteaRepo[]> {
|
||||
const repos: GiteaRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaRepo[]>(`${GITEA_API_URL}/user/repos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
limit: perPage,
|
||||
page,
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGiteaIssues(args: GiteaFetchIssuesArgs): Promise<GiteaIssue[]> {
|
||||
const issues: GiteaIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaIssue[]>(`${GITEA_API_URL}/repos/${args.repoFullName}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
// Gitea returns pull requests from the issues endpoint too — this excludes them
|
||||
type: 'issues',
|
||||
limit: perPage,
|
||||
page,
|
||||
...(args.since ? { since: args.since } : {}),
|
||||
},
|
||||
});
|
||||
issues.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGiteaWebhook(args: GiteaCreateWebhookArgs): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/hooks`,
|
||||
{
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['issues'],
|
||||
config: {
|
||||
url: args.webhookUrl,
|
||||
content_type: 'json',
|
||||
secret: args.secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGiteaWebhookSignature(args: GiteaVerifyWebhookSignatureArgs): boolean {
|
||||
const expected = createHmac('sha256', args.secret).update(args.rawBody).digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(args.signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGiteaIssueState(args: GiteaUpdateIssueStateArgs): Promise<void> {
|
||||
await axios.patch(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/issues/${args.issueNumber}`,
|
||||
{ state: args.state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const IntegrationsArkTypeAdd = type({
|
||||
provider: "'github' | 'gitlab'",
|
||||
provider: "'github' | 'gitlab' | 'gitea'",
|
||||
repoFullName: 'string',
|
||||
projectId: 'number',
|
||||
});
|
||||
@@ -30,10 +30,43 @@ export const IntegrationsArkTypeSelectRepo = type({
|
||||
});
|
||||
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
|
||||
|
||||
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
userId: number;
|
||||
projectId: number;
|
||||
provider: 'github' | 'gitlab';
|
||||
provider: IntegrationProvider;
|
||||
};
|
||||
|
||||
export type GiteaFetchIssuesArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export type GiteaCreateWebhookArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
webhookUrl: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaVerifyWebhookSignatureArgs = {
|
||||
rawBody: Buffer;
|
||||
signature: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaUpdateIssueStateArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
issueNumber: number;
|
||||
state: 'open' | 'closed';
|
||||
};
|
||||
|
||||
export type IntegrationsDebugLogEntry = {
|
||||
step: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type RepoItemForClient = {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { MessagingManager } from './MessagingManager';
|
||||
import { SlackInboundManager } from './SlackInboundManager';
|
||||
import { SLACK_OAUTH_NONCE_COOKIE } from './config';
|
||||
import {
|
||||
MessagingArkTypeConnectLink,
|
||||
MessagingArkTypeById,
|
||||
MessagingArkTypeToggle,
|
||||
MessagingArkTypeUpdateEvents,
|
||||
MessagingArkTypeProviderParam,
|
||||
MessagingArkTypeProjectToggle,
|
||||
MessagingArkTypeProjectDelete,
|
||||
MessagingArkTypeProjectPostContent,
|
||||
} from './types';
|
||||
import { getAuthorizedGoalId } from './middlewares/ProjectMessagingPermission';
|
||||
|
||||
export class MessagingController {
|
||||
private readonly manager = new MessagingManager();
|
||||
private readonly slackInbound = new SlackInboundManager();
|
||||
|
||||
// Slash command (/task). Signature already verified by VerifySlackRequest.
|
||||
slackCommands = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const reply = await this.slackInbound.handleSlashCommand(req.body ?? {});
|
||||
return res.json(reply);
|
||||
} catch (err) {
|
||||
$logger.error(err, '[Messaging/Slack] slash command failed');
|
||||
return res.json({ response_type: 'ephemeral', text: 'Something went wrong handling that command.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Button clicks and modal submissions. Payload is a JSON string in the `payload` field.
|
||||
slackInteractivity = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = (req.body as { payload?: unknown })?.payload;
|
||||
const interaction = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
const result = await this.slackInbound.handleInteraction(interaction ?? {});
|
||||
return res.json(result ?? {});
|
||||
} catch (err) {
|
||||
$logger.error(err, '[Messaging/Slack] interactivity failed');
|
||||
return res.status(200).end();
|
||||
}
|
||||
};
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.fetchPersonalConnections(userId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
connectLink = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeConnectLink({ ...req.params, ...req.query });
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.createPersonalConnectLink(userId, data.provider);
|
||||
if (!result) return res.status(503).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
toggle = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeToggle(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.togglePersonal(data.id, userId, data.isActive);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.deletePersonal(data.id, userId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
updateEvents = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeUpdateEvents(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.updatePersonalEvents(data.id, userId, data.events);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchProject = async (_req: Request, res: Response) => {
|
||||
const result = await this.manager.fetchProjectConnections(getAuthorizedGoalId(res));
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
projectConnectLink = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProviderParam(req.params);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).end();
|
||||
|
||||
const result = await this.manager.createProjectConnectLink(getAuthorizedGoalId(res), userId, data.provider);
|
||||
if (!result) return res.status(503).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
toggleProject = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProjectToggle(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.toggleProject(data.id, getAuthorizedGoalId(res), data.isActive);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
deleteProject = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProjectDelete(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.deleteProject(data.id, getAuthorizedGoalId(res));
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
updateProjectEvents = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeUpdateEvents(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.updateProjectEvents(data.id, getAuthorizedGoalId(res), data.events);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
updateProjectPostContent = async (req: Request, res: Response) => {
|
||||
const data = MessagingArkTypeProjectPostContent(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.updateProjectPostContent(data.id, getAuthorizedGoalId(res), data.postContent);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
// Slack uses OAuth (browser redirect), so the user's JWT is passed as ?token and
|
||||
// validated manually — a full-page redirect can't carry the API Authorization header.
|
||||
slackOAuthStart = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.query.token as string;
|
||||
if (!token) return res.status(401).send('token is required');
|
||||
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
const userId = userPayload?.userData?.id;
|
||||
if (!userId) return res.status(401).send('Invalid token');
|
||||
|
||||
if (!this.manager.getProvider('slack')?.isConfigured()) {
|
||||
return res.status(503).send('Slack is not configured on the server (set SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_CALLBACK_URL)');
|
||||
}
|
||||
|
||||
const scope: 'user' | 'project' = req.query.scope === 'project' ? 'project' : 'user';
|
||||
let ownerId = userId;
|
||||
|
||||
if (scope === 'project') {
|
||||
const goalId = Number(req.query.goalId);
|
||||
if (!goalId || Number.isNaN(goalId)) return res.status(400).send('goalId is required');
|
||||
const checker = await new AppUser(userPayload).permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) return res.status(403).end();
|
||||
ownerId = goalId;
|
||||
}
|
||||
|
||||
const returnPath = this.safeReturnPath(req.query.returnPath);
|
||||
const start = await this.manager.startOAuthConnect('slack', { ownerType: scope, ownerId, userId, returnPath });
|
||||
if (!start) return res.status(503).send('Slack is not configured on the server');
|
||||
// The provider hands back the anti-CSRF nonce cookie to set on this browser.
|
||||
if (start.kind === 'oauth') {
|
||||
res.cookie(start.setCookie.name, start.setCookie.value, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: start.setCookie.maxAgeMs,
|
||||
});
|
||||
}
|
||||
return res.redirect(start.url);
|
||||
} catch (err) {
|
||||
$logger.error(err, '[Messaging/Slack] Failed to start OAuth');
|
||||
return res.status(500).send('Failed to start Slack OAuth');
|
||||
}
|
||||
};
|
||||
|
||||
slackOAuthCallback = async (req: Request, res: Response) => {
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
const nonce = req.cookies?.[SLACK_OAUTH_NONCE_COOKIE] as string | undefined;
|
||||
res.clearCookie(SLACK_OAUTH_NONCE_COOKIE);
|
||||
if (!code || !state) return res.redirect(`${process.env.APP_URL}?messaging=error`);
|
||||
try {
|
||||
const result = await this.manager.handleInbound('slack', { source: 'oauth-callback', payload: { code, state }, cookie: nonce });
|
||||
return res.redirect(`${process.env.APP_URL}${this.safeReturnPath(result?.redirect)}?messaging=connected`);
|
||||
} catch (err) {
|
||||
$logger.error({ error: err instanceof Error ? err.message : String(err), hasNonce: !!nonce }, '[Messaging/Slack] OAuth callback failed');
|
||||
return res.redirect(`${process.env.APP_URL}?messaging=error`);
|
||||
}
|
||||
};
|
||||
|
||||
private safeReturnPath(value: unknown): string {
|
||||
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')
|
||||
? value
|
||||
: '';
|
||||
}
|
||||
|
||||
telegramWebhook = async (req: Request, res: Response) => {
|
||||
res.status(200).end();
|
||||
await this.manager.handleInbound('telegram', { source: 'webhook', payload: req.body });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { SprintsSchema, TasksSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { GoalPermissionsRepository } from '../../core/GoalPermissionsRepository';
|
||||
import { GoalPermissionsChecker } from '../../core/GoalPermissionsChecker';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { MessagingRepository } from './MessagingRepository';
|
||||
import { MessagingManager } from './MessagingManager';
|
||||
import { buildMessagingMessage } from './messages';
|
||||
import { buildTaskDeepLink } from './utils';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { MessagingDeliverJobData, MessagingEvent, MessagingProviderId } from './types';
|
||||
import type { MessagingDispatchArgs, MessagingRecipient, MessagingTaskContext } from './types.internal';
|
||||
|
||||
const MESSAGING_DELIVER_JOB = 'messaging-deliver';
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
export class MessagingDispatcher implements Dispatcher {
|
||||
private readonly repository = new MessagingRepository();
|
||||
private readonly manager = new MessagingManager();
|
||||
private readonly permissionsRepo = new GoalPermissionsRepository();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.onTaskCreated(data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.onTaskAssigned(data));
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
eventBus.on('task.assignedToSprint', (data) => this.onTaskAddedToSprint(data));
|
||||
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
|
||||
|
||||
eventBus.on('sprint.created', (data) => this.onSprintObj('sprint.created', data));
|
||||
eventBus.on('sprint.updated', (data) => this.onSprintObj('sprint.updated', data));
|
||||
eventBus.on('sprint.activated', (data) => this.onSprint('sprint.started', data));
|
||||
eventBus.on('sprint.reviewStarted', (data) => this.onSprint('sprint.reviewStarted', data));
|
||||
eventBus.on('sprint.completed', (data) => this.onSprint('sprint.completed', data));
|
||||
eventBus.on('sprint.paused', (data) => this.onSprint('sprint.paused', data));
|
||||
eventBus.on('sprint.resumed', (data) => this.onSprint('sprint.resumed', data));
|
||||
eventBus.on('sprint.deleted', (data) => this.onSprint('sprint.deleted', data));
|
||||
|
||||
eventBus.on('collaboration.userAdded', (data) => this.onMember('member.added', data.goalId, data.email, data.initiatorId));
|
||||
eventBus.on('collaboration.userRemoved', (data) => this.onMemberByCollab('member.removed', data.goalId, data.collaborationUserId, data.initiatorId));
|
||||
eventBus.on('collaboration.rolesChanged', (data) => this.onMemberByCollab('member.rolesChanged', data.goalId, data.collaborationUserId, data.initiatorId));
|
||||
|
||||
eventBus.on('time-entry.started', (data) => this.onTimeEntry('time.started', data.goalId, data.taskId, data.userId));
|
||||
eventBus.on('time-entry.stopped', (data) => this.onTimeEntry('time.stopped', data.goalId, data.taskId, data.userId));
|
||||
eventBus.on('time-entry.created', (data) => this.onTimeEntry('time.logged', data.entry.goalId, data.entry.taskId, data.initiatorId));
|
||||
eventBus.on('time-entry.updated', (data) => this.onTimeEntry('time.updated', data.entry.goalId, data.entry.taskId, data.initiatorId));
|
||||
eventBus.on('time-entry.deleted', (data) => this.onTimeEntry('time.deleted', data.goalId, data.taskId, data.initiatorId));
|
||||
|
||||
eventBus.on('recurrence.created', (data) => this.onRecurrence('recurrence.created', data.rule.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.updated', (data) => this.onRecurrence('recurrence.updated', data.rule.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.paused', (data) => this.onRecurrence('recurrence.paused', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.resumed', (data) => this.onRecurrence('recurrence.resumed', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.ended', (data) => this.onRecurrence('recurrence.ended', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.deleted', (data) => this.onRecurrence('recurrence.deleted', data.goalId, data.initiatorId));
|
||||
eventBus.on('recurrence.instanceSkipped', (data) => this.onRecurrence('recurrence.skipped', data.goalId, data.initiatorId));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(MESSAGING_DELIVER_JOB);
|
||||
await boss.work<MessagingDeliverJobData>(MESSAGING_DELIVER_JOB, async ([job]) => {
|
||||
await this.deliverJob(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
|
||||
// A brand-new task usually has no assignees yet, so target project members.
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.task.goalId);
|
||||
await this.dispatch({
|
||||
event: 'task.created',
|
||||
goalId: data.task.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(data.task)
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskAssigned(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
if (data.userIds.length === 0) return;
|
||||
const task = await this.fetchTask(data.taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Keep only collab ids still assigned AND still project members, then resolve to auth recipients.
|
||||
const currentAssignees = new Set(await this.repository.fetchCurrentAssigneeCollabIds(task.id));
|
||||
const stillAssigned = data.userIds.filter(id => currentAssignees.has(id));
|
||||
const memberCollabIds = await this.repository.filterCollabIdsInGoal(stillAssigned, task.goalId);
|
||||
if (memberCollabIds.length === 0) return;
|
||||
const recipients = await this.repository.resolveCollabIdsToRecipients(memberCollabIds);
|
||||
|
||||
await this.dispatch({
|
||||
event: 'task.assigned',
|
||||
goalId: task.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(task)
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
const changes = data.changes ?? {};
|
||||
let event: MessagingEvent;
|
||||
let titleOverride: string | undefined;
|
||||
if ('complete' in changes && data.task.complete === true) event = 'task.completed';
|
||||
else if ('complete' in changes && data.task.complete === false) {
|
||||
// Reopen: gated by the same task.completed subscription, but shown as "reopened".
|
||||
event = 'task.completed';
|
||||
titleOverride = '[Task reopened]';
|
||||
} else if ('statusId' in changes) event = 'task.statusChanged';
|
||||
else event = 'task.edited';
|
||||
|
||||
const recipients = await this.taskAssigneeRecipients(data.task.id, data.task.goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId: data.task.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(data.task),
|
||||
titleOverride
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskAddedToSprint(data: AppEvents['task.assignedToSprint']): Promise<void> {
|
||||
if (!data.sprintId) return;
|
||||
const task = await this.fetchTask(data.taskId);
|
||||
if (!task) return;
|
||||
const recipients = await this.taskAssigneeRecipients(data.taskId, data.goalId);
|
||||
await this.dispatch({
|
||||
event: 'task.addedToSprint',
|
||||
goalId: data.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
task: this.taskCtx(task)
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
|
||||
await this.dispatch({
|
||||
event: 'task.deleted',
|
||||
goalId: data.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
body: `#${data.taskId}`
|
||||
});
|
||||
}
|
||||
|
||||
private async onSprintObj(event: MessagingEvent, data: { sprint: { goalId: number; name: string }; initiatorId: number }): Promise<void> {
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.sprint.goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId: data.sprint.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
body: data.sprint.name
|
||||
});
|
||||
}
|
||||
|
||||
private async onSprint(event: MessagingEvent, data: { sprintId: number; goalId: number; initiatorId: number | null }): Promise<void> {
|
||||
const name = await this.fetchSprintName(data.sprintId);
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId: data.goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId: data.initiatorId,
|
||||
body: name
|
||||
});
|
||||
}
|
||||
|
||||
private async onMember(event: MessagingEvent, goalId: number, body: string, initiatorId: number): Promise<void> {
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId,
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
private async onMemberByCollab(event: MessagingEvent, goalId: number, collabId: number, initiatorId: number): Promise<void> {
|
||||
const email = await this.repository.fetchCollabEmail(collabId);
|
||||
await this.onMember(event, goalId, email ?? '', initiatorId);
|
||||
}
|
||||
|
||||
private async onTimeEntry(event: MessagingEvent, goalId: number, taskId: number, initiatorId: number | null): Promise<void> {
|
||||
const task = await this.fetchTask(taskId);
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
|
||||
// Time entries reference a task → gate the task description like task events.
|
||||
await this.dispatch({
|
||||
event,
|
||||
goalId,
|
||||
personalRecipients: recipients,
|
||||
initiatorId,
|
||||
task: task ? this.taskCtx(task) : undefined,
|
||||
body: task ? undefined : `#${taskId}`
|
||||
});
|
||||
}
|
||||
|
||||
private async onRecurrence(event: MessagingEvent, goalId: number, initiatorId: number): Promise<void> {
|
||||
// Recurring-rule template content is not shown (avoids leaking gated task content); title only.
|
||||
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
|
||||
await this.dispatch({ event, goalId, personalRecipients: recipients, initiatorId, body: '' });
|
||||
}
|
||||
|
||||
private async dispatch(args: MessagingDispatchArgs): Promise<void> {
|
||||
// No initiator exclusion: messaging is an explicit opt-in feed — if you
|
||||
// subscribed to an event you receive it, even for your own actions.
|
||||
const url = args.task ? await this.buildTaskUrl(args.goalId, args.task.id, args.task.goalListId) : undefined;
|
||||
const projectName = await this.repository.fetchGoalName(args.goalId);
|
||||
// Assignee emails are RBAC-gated content. Compute once, then include per recipient
|
||||
// only when they may see assignees (TASKS_CAN_WATCH_ASSIGNED_USERS) — same treatment
|
||||
// as the description, which is gated by COMPONENT_CAN_WATCH_CONTENT.
|
||||
const footerText = args.task ? await this.assigneesFooter(args.task.id) : undefined;
|
||||
|
||||
let personalCount = 0;
|
||||
for (const r of args.personalRecipients) {
|
||||
const checker = args.task ? await this.checkerForRecipient(args.goalId, r) : null;
|
||||
const body = this.bodyForRecipient(args, checker);
|
||||
const footer = footerText && checker?.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS) ? footerText : undefined;
|
||||
const message = buildMessagingMessage({
|
||||
event: args.event,
|
||||
audience: 'personal',
|
||||
body,
|
||||
url,
|
||||
taskId: args.task?.id,
|
||||
projectName,
|
||||
footer,
|
||||
completed: args.task?.complete,
|
||||
titleOverride: args.titleOverride
|
||||
});
|
||||
const connections = await this.subscribed('user', r.userId, args.event);
|
||||
personalCount += connections.length;
|
||||
await this.enqueueAll(connections, message);
|
||||
}
|
||||
|
||||
// Project channel: a shared channel's membership doesn't map to TaskView content
|
||||
// permission. By default the description AND assignees ARE posted (a channel is a
|
||||
// deliberate broadcast); a connection can opt out via post_content — then task events
|
||||
// get title + link only (no description, no assignee emails). Non-task bodies
|
||||
// (sprint name, …) are not RBAC-gated content.
|
||||
const projectConnections = await this.subscribed('project', args.goalId, args.event);
|
||||
for (const connection of projectConnections) {
|
||||
const projectBody = args.task
|
||||
? (connection.postContent ? (args.task.description ?? `#${args.task.id}`) : '')
|
||||
: (args.body ?? '');
|
||||
const projectFooter = connection.postContent ? footerText : undefined;
|
||||
// A content-hidden channel gets title + link only — no description, no assignee footer,
|
||||
// and no action buttons (their modals would expose members / task state).
|
||||
const projectMessage = buildMessagingMessage({
|
||||
event: args.event,
|
||||
audience: 'project',
|
||||
body: projectBody,
|
||||
url,
|
||||
taskId: args.task?.id,
|
||||
projectName,
|
||||
footer: projectFooter,
|
||||
completed: args.task?.complete,
|
||||
titleOverride: args.titleOverride,
|
||||
actions: connection.postContent
|
||||
});
|
||||
await this.enqueueAll([connection], projectMessage);
|
||||
}
|
||||
|
||||
$logger.info(
|
||||
{ event: args.event, goalId: args.goalId, members: args.personalRecipients.length, personalConns: personalCount, projectConns: projectConnections.length },
|
||||
'[Messaging] dispatch',
|
||||
);
|
||||
}
|
||||
|
||||
private bodyForRecipient(args: MessagingDispatchArgs, checker: GoalPermissionsChecker | null): string {
|
||||
if (!args.task) return args.body ?? '';
|
||||
const canWatch = checker?.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) ?? false;
|
||||
return canWatch ? (args.task.description ?? `#${args.task.id}`) : `#${args.task.id}`;
|
||||
}
|
||||
|
||||
/** Per-recipient permission checker for the goal; fail-closed (empty) on lookup error. */
|
||||
private async checkerForRecipient(goalId: number, recipient: MessagingRecipient): Promise<GoalPermissionsChecker> {
|
||||
const permissions = await this.permissionsRepo
|
||||
.fetchPermissionsForGoalByUser({ goalId, userId: recipient.userId, email: recipient.email })
|
||||
.catch((err) => {
|
||||
$logger.error(err, `[Messaging] permission check failed for user=${recipient.userId}`);
|
||||
return [];
|
||||
});
|
||||
return new GoalPermissionsChecker(permissions);
|
||||
}
|
||||
|
||||
private async buildTaskUrl(goalId: number, taskId: number, goalListId: number | null): Promise<string | undefined> {
|
||||
const orgSlug = await this.repository.fetchGoalOrgSlug(goalId);
|
||||
return buildTaskDeepLink(orgSlug, goalId, taskId, goalListId);
|
||||
}
|
||||
|
||||
/** "👤 email1, email2" of the task's current assignees, or undefined if none. */
|
||||
private async assigneesFooter(taskId: number): Promise<string | undefined> {
|
||||
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
|
||||
if (collabIds.length === 0) return undefined;
|
||||
const assignees = await this.repository.resolveCollabIdsToRecipients(collabIds);
|
||||
if (assignees.length === 0) return undefined;
|
||||
return `👤 ${assignees.map((a) => a.email).join(', ')}`;
|
||||
}
|
||||
|
||||
private async subscribed(ownerType: string, ownerId: number, event: MessagingEvent): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
|
||||
const connections = await this.repository.fetchActiveByOwner(ownerType, ownerId);
|
||||
return connections.filter(c => c.events.includes(event));
|
||||
}
|
||||
|
||||
private async taskAssigneeRecipients(taskId: number, goalId: number): Promise<MessagingRecipient[]> {
|
||||
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
|
||||
const memberCollabIds = await this.repository.filterCollabIdsInGoal(collabIds, goalId);
|
||||
return this.repository.resolveCollabIdsToRecipients(memberCollabIds);
|
||||
}
|
||||
|
||||
private taskCtx(task: { id: number; goalListId: number | null; description: string | null; complete: boolean | null }): MessagingTaskContext {
|
||||
return { id: task.id, goalListId: task.goalListId, description: task.description, complete: task.complete === true };
|
||||
}
|
||||
|
||||
private async fetchTask(taskId: number) {
|
||||
const db = Database.getInstance();
|
||||
const rows = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, taskId)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async fetchSprintName(sprintId: number): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
const rows = await db.dbDrizzle.select({ name: SprintsSchema.name }).from(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).limit(1);
|
||||
return rows[0]?.name ?? `#${sprintId}`;
|
||||
}
|
||||
|
||||
private async enqueueAll(connections: MessagingConnectionsSchemaTypeForSelect[], message: ReturnType<typeof buildMessagingMessage>): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
for (const connection of connections) {
|
||||
await boss.send(MESSAGING_DELIVER_JOB, {
|
||||
connectionId: connection.id,
|
||||
provider: connection.provider as MessagingProviderId,
|
||||
chatId: connection.targetChatId,
|
||||
accessTokenEncrypted: connection.accessTokenEncrypted,
|
||||
message,
|
||||
attempt: 1,
|
||||
} satisfies MessagingDeliverJobData);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverJob(data: MessagingDeliverJobData): Promise<void> {
|
||||
const provider = this.manager.getProvider(data.provider);
|
||||
if (!provider || !provider.isConfigured()) return;
|
||||
|
||||
const result = await provider.deliver({
|
||||
chatId: data.chatId,
|
||||
accessToken: data.accessTokenEncrypted ? decrypt(data.accessTokenEncrypted) : null,
|
||||
message: data.message,
|
||||
});
|
||||
|
||||
$logger.info({ connectionId: data.connectionId, provider: data.provider, success: result.success, errorCode: result.errorCode }, '[Messaging] deliver result');
|
||||
|
||||
if (result.success) return;
|
||||
|
||||
if (data.attempt < MAX_ATTEMPTS) {
|
||||
const boss = getJobQueue();
|
||||
const delay = Math.pow(2, data.attempt) * 5;
|
||||
await boss.send(MESSAGING_DELIVER_JOB, { ...data, attempt: data.attempt + 1 }, { startAfter: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.warn(`[Messaging] Delivery failed for connection=${data.connectionId} after ${MAX_ATTEMPTS} attempts`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { MessagingRepository } from './MessagingRepository';
|
||||
import { TelegramProvider } from './providers/telegram.provider';
|
||||
import { SlackProvider } from './providers/slack.provider';
|
||||
import { buildTaskDeepLink } from './utils';
|
||||
import type { MessagingProvider } from './providers/MessagingProvider';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingConnectionForClient, MessagingConnectLinkResult } from './types.internal';
|
||||
import { sanitizeMessagingEvents, type MessagingMessage, type MessagingProviderId } from './types';
|
||||
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export class MessagingManager {
|
||||
public readonly repository = new MessagingRepository();
|
||||
private readonly telegram = new TelegramProvider();
|
||||
private readonly slack = new SlackProvider();
|
||||
private readonly providers: Map<MessagingProviderId, MessagingProvider>;
|
||||
|
||||
constructor() {
|
||||
this.providers = new Map<MessagingProviderId, MessagingProvider>([
|
||||
['telegram', this.telegram],
|
||||
['slack', this.slack],
|
||||
]);
|
||||
}
|
||||
|
||||
getProvider(id: MessagingProviderId): MessagingProvider | undefined {
|
||||
return this.providers.get(id);
|
||||
}
|
||||
|
||||
private async beginConnect(id: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
|
||||
const provider = this.getProvider(id);
|
||||
if (!provider?.isConfigured()) return null;
|
||||
const start = await provider.startConnect(ctx);
|
||||
if (start.kind === 'deep-link') {
|
||||
const row = await this.repository.createLinkToken({
|
||||
token: start.persistToken.token,
|
||||
provider: id,
|
||||
ownerType: ctx.ownerType,
|
||||
ownerId: ctx.ownerId,
|
||||
createdBy: ctx.userId,
|
||||
expiresAt: start.persistToken.expiresAt,
|
||||
});
|
||||
if (!row) return null;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
async createPersonalConnectLink(userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
|
||||
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'user', ownerId: userId, userId }));
|
||||
}
|
||||
|
||||
async createProjectConnectLink(goalId: number, userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
|
||||
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'project', ownerId: goalId, userId }));
|
||||
}
|
||||
|
||||
private toLinkResult(provider: MessagingProviderId, start: ConnectStart | null): MessagingConnectLinkResult | null {
|
||||
if (start?.kind !== 'deep-link') return null;
|
||||
return { provider, url: start.url, token: start.persistToken.token, expiresAt: start.persistToken.expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
async startOAuthConnect(provider: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
|
||||
return this.beginConnect(provider, ctx);
|
||||
}
|
||||
|
||||
async handleInbound(id: MessagingProviderId, raw: InboundRaw): Promise<{ redirect?: string } | null> {
|
||||
const provider = this.getProvider(id);
|
||||
if (!provider) return null;
|
||||
const intent = await provider.parseInbound(raw);
|
||||
if (!intent) return null;
|
||||
|
||||
if (intent.kind === 'createConnection') {
|
||||
await this.repository.createConnection(intent.connection);
|
||||
if (intent.identity) await this.repository.upsertIdentity({ userId: intent.identity.userId, provider: id, externalUserId: intent.identity.externalUserId, externalTeamId: intent.identity.externalTeamId });
|
||||
return { redirect: intent.redirect };
|
||||
}
|
||||
|
||||
if (intent.kind === 'command') {
|
||||
const reply = await this.runCreateTask(id, intent);
|
||||
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: reply });
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.bindByToken(id, provider, intent);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async runCreateTask(id: MessagingProviderId, intent: Extract<InboundIntent, { kind: 'command' }>): Promise<MessagingMessage> {
|
||||
const note = (title: string): MessagingMessage => ({ event: 'task.created', title });
|
||||
|
||||
const userId = await this.repository.findUserIdByExternalId({ provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
|
||||
if (!userId) return note('Link your account in TaskView first (Personal → Connect), then try /task again.');
|
||||
|
||||
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: id, channelId: intent.chatId, externalTeamId: null });
|
||||
if (goalIds.length === 0) return note('This chat is not linked to a TaskView project.');
|
||||
if (goalIds.length > 1) return note('This chat is linked to several projects — create the task in TaskView.');
|
||||
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return note('Could not resolve your TaskView account.');
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
|
||||
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
|
||||
return note('You do not have permission to create tasks in this project.');
|
||||
}
|
||||
|
||||
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description: intent.text });
|
||||
const task = created?.[0];
|
||||
if (!task) return note('Failed to create the task.');
|
||||
|
||||
const orgSlug = await this.repository.fetchGoalOrgSlug(goalIds[0]);
|
||||
const url = buildTaskDeepLink(orgSlug, goalIds[0], task.id, task.goalListId ?? null);
|
||||
return { event: 'task.created', title: '✅ Task created', body: intent.text, url };
|
||||
}
|
||||
|
||||
private async buildAppUser(userId: number): Promise<AppUser | null> {
|
||||
const record = await new AppUser().authManager.repository.fetchUserById(userId);
|
||||
if (!record || record.block !== 0) return null;
|
||||
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
|
||||
}
|
||||
|
||||
private async bindByToken(id: MessagingProviderId, provider: MessagingProvider, intent: Extract<InboundIntent, { kind: 'bindByToken' }>): Promise<void> {
|
||||
const link = await this.repository.findValidLinkToken(id, intent.token);
|
||||
// Silently drop unknown/expired tokens or a token used in the wrong chat kind — no
|
||||
// reply, so the endpoint can't be used as an outbound-message amplifier.
|
||||
if (!link || link.ownerType !== intent.scope) return;
|
||||
|
||||
const connection = await this.repository.createConnection({
|
||||
provider: id,
|
||||
ownerType: link.ownerType,
|
||||
ownerId: link.ownerId,
|
||||
targetChatId: intent.chatId,
|
||||
title: intent.title,
|
||||
externalTeamId: null,
|
||||
accessTokenEncrypted: null,
|
||||
});
|
||||
if (!connection) {
|
||||
$logger.error(`[Messaging] Failed to create ${id} connection for ${link.ownerType}=${link.ownerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Telegram user IDs are globally unique, so there is no workspace to scope by.
|
||||
if (intent.scope === 'user') await this.repository.upsertIdentity({ userId: link.ownerId, provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
|
||||
await this.repository.consumeLinkToken(link.id);
|
||||
|
||||
const text = intent.scope === 'user'
|
||||
? 'Done! Your TaskView account is linked — notifications will be delivered here.'
|
||||
: 'Done! This chat is connected to your TaskView project — project events will be posted here.';
|
||||
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: { event: 'task.created', title: text } });
|
||||
}
|
||||
|
||||
async fetchPersonalConnections(userId: number): Promise<MessagingConnectionForClient[]> {
|
||||
const connections = await this.repository.fetchByOwner('user', userId);
|
||||
return connections.map(c => this.toClient(c));
|
||||
}
|
||||
|
||||
async togglePersonal(id: number, userId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.setActiveOwned({ id, ownerType: 'user', ownerId: userId, isActive });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async deletePersonal(id: number, userId: number): Promise<boolean> {
|
||||
return this.repository.deleteOwned({ id, ownerType: 'user', ownerId: userId });
|
||||
}
|
||||
|
||||
async updatePersonalEvents(id: number, userId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'user', ownerId: userId, events: sanitizeMessagingEvents(events) });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async fetchProjectConnections(goalId: number): Promise<MessagingConnectionForClient[]> {
|
||||
const connections = await this.repository.fetchByOwner('project', goalId);
|
||||
return connections.map(c => this.toClient(c));
|
||||
}
|
||||
|
||||
async toggleProject(id: number, goalId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
|
||||
// goalId was verified against the caller by IsGoalOwnerByGoalId; the WHERE
|
||||
// clause also binds the connection to that goal, so no cross-project mutation.
|
||||
const updated = await this.repository.setActiveOwned({ id, ownerType: 'project', ownerId: goalId, isActive });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async deleteProject(id: number, goalId: number): Promise<boolean> {
|
||||
return this.repository.deleteOwned({ id, ownerType: 'project', ownerId: goalId });
|
||||
}
|
||||
|
||||
async updateProjectEvents(id: number, goalId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'project', ownerId: goalId, events: sanitizeMessagingEvents(events) });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
async updateProjectPostContent(id: number, goalId: number, postContent: boolean): Promise<MessagingConnectionForClient | null> {
|
||||
const updated = await this.repository.setPostContentOwned({ id, ownerType: 'project', ownerId: goalId, postContent });
|
||||
return updated ? this.toClient(updated) : null;
|
||||
}
|
||||
|
||||
private toClient(connection: MessagingConnectionsSchemaTypeForSelect): MessagingConnectionForClient {
|
||||
const { accessTokenEncrypted, ...rest } = connection;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { and, eq, gt, inArray, isNull } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
CollaborationUsersSchema,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
GoalsSchema,
|
||||
MessagingConnectionsSchema,
|
||||
MessagingIdentityMapSchema,
|
||||
MessagingLinkTokensSchema,
|
||||
OrganizationsSchema,
|
||||
TasksAssigneeSchema,
|
||||
UsersSchema,
|
||||
type MessagingConnectionsSchemaTypeForSelect,
|
||||
type MessagingLinkTokensSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import { SLACK_WEBHOOK_PREFIX } from './config';
|
||||
import type {
|
||||
MessagingChannelLookup,
|
||||
MessagingConnectionCreate,
|
||||
MessagingIdentityLookup,
|
||||
MessagingIdentityUpsert,
|
||||
MessagingLinkTokenCreate,
|
||||
MessagingOwnedRef,
|
||||
MessagingOwnedToggle,
|
||||
MessagingRecipient,
|
||||
} from './types.internal';
|
||||
|
||||
export class MessagingRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async createConnection(data: MessagingConnectionCreate): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(MessagingConnectionsSchema)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
MessagingConnectionsSchema.provider,
|
||||
MessagingConnectionsSchema.ownerType,
|
||||
MessagingConnectionsSchema.ownerId,
|
||||
MessagingConnectionsSchema.targetChatId,
|
||||
],
|
||||
set: {
|
||||
title: data.title,
|
||||
externalTeamId: data.externalTeamId,
|
||||
accessTokenEncrypted: data.accessTokenEncrypted,
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(eq(MessagingConnectionsSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
|
||||
and(
|
||||
eq(MessagingConnectionsSchema.ownerType, ownerType),
|
||||
eq(MessagingConnectionsSchema.ownerId, ownerId),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchActiveByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
|
||||
and(
|
||||
eq(MessagingConnectionsSchema.ownerType, ownerType),
|
||||
eq(MessagingConnectionsSchema.ownerId, ownerId),
|
||||
eq(MessagingConnectionsSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async setActiveOwned(args: MessagingOwnedToggle): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(MessagingConnectionsSchema)
|
||||
.set({ isActive: args.isActive, updatedAt: new Date() })
|
||||
.where(this.ownedWhere(args))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteOwned(args: MessagingOwnedRef): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(MessagingConnectionsSchema).where(this.ownedWhere(args))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
private ownedWhere(args: MessagingOwnedRef) {
|
||||
return and(
|
||||
eq(MessagingConnectionsSchema.id, args.id),
|
||||
eq(MessagingConnectionsSchema.ownerType, args.ownerType),
|
||||
eq(MessagingConnectionsSchema.ownerId, args.ownerId),
|
||||
);
|
||||
}
|
||||
|
||||
async resolveCollabIdsToRecipients(collabIds: number[]): Promise<MessagingRecipient[]> {
|
||||
if (collabIds.length === 0) return [];
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(inArray(CollaborationUsersSchema.id, collabIds))
|
||||
);
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
async updateEventsOwned(args: MessagingOwnedRef & { events: string[] }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(MessagingConnectionsSchema)
|
||||
.set({ events: args.events, updatedAt: new Date() })
|
||||
.where(this.ownedWhere(args))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async setPostContentOwned(args: MessagingOwnedRef & { postContent: boolean }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(MessagingConnectionsSchema)
|
||||
.set({ postContent: args.postContent, updatedAt: new Date() })
|
||||
.where(this.ownedWhere(args))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchCollabEmail(collabId: number): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.where(eq(CollaborationUsersSchema.id, collabId))
|
||||
.limit(1)
|
||||
);
|
||||
return rows?.[0]?.email ?? null;
|
||||
}
|
||||
|
||||
async fetchProjectMemberRecipients(goalId: number): Promise<MessagingRecipient[]> {
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
async fetchGoalName(goalId: number): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ name: GoalsSchema.name }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1)
|
||||
);
|
||||
return rows?.[0]?.name ?? null;
|
||||
}
|
||||
|
||||
async fetchGoalOrgSlug(goalId: number): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
|
||||
.from(GoalsSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(GoalsSchema.id, goalId))
|
||||
.limit(1)
|
||||
);
|
||||
return rows?.[0]?.slug ?? null;
|
||||
}
|
||||
|
||||
async filterCollabIdsInGoal(collabIds: number[], goalId: number): Promise<number[]> {
|
||||
if (collabIds.length === 0) return [];
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: CollaborationUsersToGoalsSchema.userId })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.where(and(
|
||||
eq(CollaborationUsersToGoalsSchema.goalId, goalId),
|
||||
inArray(CollaborationUsersToGoalsSchema.userId, collabIds),
|
||||
))
|
||||
);
|
||||
return (rows ?? []).map(r => r.userId);
|
||||
}
|
||||
|
||||
async fetchCurrentAssigneeCollabIds(taskId: number): Promise<number[]> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ collabUserId: TasksAssigneeSchema.collabUserId })
|
||||
.from(TasksAssigneeSchema)
|
||||
.where(eq(TasksAssigneeSchema.taskId, taskId))
|
||||
);
|
||||
return (rows ?? []).map(r => r.collabUserId);
|
||||
}
|
||||
|
||||
async createLinkToken(data: MessagingLinkTokenCreate): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(MessagingLinkTokensSchema)
|
||||
.values({ ...data, token: this.hashToken(data.token) })
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async findValidLinkToken(provider: string, token: string): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(MessagingLinkTokensSchema).where(
|
||||
and(
|
||||
eq(MessagingLinkTokensSchema.provider, provider),
|
||||
eq(MessagingLinkTokensSchema.token, this.hashToken(token)),
|
||||
gt(MessagingLinkTokensSchema.expiresAt, new Date()),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async consumeLinkToken(id: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(MessagingLinkTokensSchema).where(eq(MessagingLinkTokensSchema.id, id))
|
||||
);
|
||||
}
|
||||
|
||||
async findUserIdByExternalId(args: MessagingIdentityLookup): Promise<number | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ userId: MessagingIdentityMapSchema.userId })
|
||||
.from(MessagingIdentityMapSchema)
|
||||
.where(and(
|
||||
eq(MessagingIdentityMapSchema.provider, args.provider),
|
||||
eq(MessagingIdentityMapSchema.externalUserId, args.externalUserId),
|
||||
args.externalTeamId === null
|
||||
? isNull(MessagingIdentityMapSchema.externalTeamId)
|
||||
: eq(MessagingIdentityMapSchema.externalTeamId, args.externalTeamId),
|
||||
))
|
||||
.limit(1)
|
||||
);
|
||||
return rows?.[0]?.userId ?? null;
|
||||
}
|
||||
|
||||
async fetchProjectGoalIdsByChannel(args: MessagingChannelLookup): Promise<number[]> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ goalId: MessagingConnectionsSchema.ownerId })
|
||||
.from(MessagingConnectionsSchema)
|
||||
.where(and(
|
||||
eq(MessagingConnectionsSchema.provider, args.provider),
|
||||
eq(MessagingConnectionsSchema.ownerType, 'project'),
|
||||
eq(MessagingConnectionsSchema.targetChatId, args.channelId),
|
||||
eq(MessagingConnectionsSchema.isActive, true),
|
||||
args.externalTeamId === null
|
||||
? isNull(MessagingConnectionsSchema.externalTeamId)
|
||||
: eq(MessagingConnectionsSchema.externalTeamId, args.externalTeamId),
|
||||
))
|
||||
);
|
||||
return rows?.map((r) => r.goalId) ?? [];
|
||||
}
|
||||
|
||||
async fetchSlackBotTokenEncrypted(teamId: string): Promise<string | null> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ token: MessagingConnectionsSchema.accessTokenEncrypted })
|
||||
.from(MessagingConnectionsSchema)
|
||||
.where(and(
|
||||
eq(MessagingConnectionsSchema.provider, 'slack'),
|
||||
eq(MessagingConnectionsSchema.externalTeamId, teamId),
|
||||
eq(MessagingConnectionsSchema.isActive, true),
|
||||
))
|
||||
);
|
||||
return rows?.map((r) => r.token).find((t): t is string => !!t && !t.startsWith(SLACK_WEBHOOK_PREFIX)) ?? null;
|
||||
}
|
||||
|
||||
async fetchGoalCollabMembers(goalId: number): Promise<{ collabId: number; email: string }[]> {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.selectDistinct({ collabId: CollaborationUsersSchema.id, email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
async upsertIdentity(args: MessagingIdentityUpsert): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(MessagingIdentityMapSchema)
|
||||
.values({ userId: args.userId, provider: args.provider, externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: [MessagingIdentityMapSchema.userId, MessagingIdentityMapSchema.provider],
|
||||
set: { externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
|
||||
import { MessagingController } from './MessagingController'
|
||||
import { VerifyTelegramWebhook } from './middlewares/VerifyTelegramWebhook'
|
||||
import { VerifySlackRequest } from './middlewares/VerifySlackRequest'
|
||||
import { CanManageProjectMessaging, CanViewProjectMessaging } from './middlewares/ProjectMessagingPermission'
|
||||
|
||||
export default class MessagingRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: MessagingController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new MessagingController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
// Personal (per-user) connections — scoped to the authenticated user.
|
||||
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch)
|
||||
this.router.patch('/toggle', [IsLoggedIn, RejectApiTokenAuth], this.controller.toggle)
|
||||
this.router.patch('/events', [IsLoggedIn, RejectApiTokenAuth], this.controller.updateEvents)
|
||||
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete)
|
||||
this.router.get('/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth], this.controller.connectLink)
|
||||
|
||||
// Project connections — guarded by project ownership (goalId in body/query).
|
||||
this.router.get('/project', [IsLoggedIn, RejectApiTokenAuth, CanViewProjectMessaging], this.controller.fetchProject)
|
||||
this.router.patch('/project/toggle', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.toggleProject)
|
||||
this.router.patch('/project/events', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectEvents)
|
||||
this.router.patch('/project/post-content', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectPostContent)
|
||||
this.router.delete('/project', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.deleteProject)
|
||||
this.router.get('/project/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.projectConnectLink)
|
||||
|
||||
// Slack OAuth — public (auth carried by the JWT ?token on start and the signed state on callback).
|
||||
this.router.get('/slack/oauth/start', this.controller.slackOAuthStart)
|
||||
this.router.get('/slack/oauth/callback', this.controller.slackOAuthCallback)
|
||||
|
||||
// Inbound webhook — public, protected by the Telegram secret-token header.
|
||||
this.router.post('/telegram/webhook', [VerifyTelegramWebhook], this.controller.telegramWebhook)
|
||||
|
||||
// Slack inbound (slash commands + interactivity) — public, protected by the Slack signature.
|
||||
this.router.post('/slack/commands', [VerifySlackRequest], this.controller.slackCommands)
|
||||
this.router.post('/slack/interactivity', [VerifySlackRequest], this.controller.slackInteractivity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { MessagingRepository } from './MessagingRepository';
|
||||
import { SlackProvider } from './providers/slack.provider';
|
||||
import {
|
||||
SLACK_ACTION_ASSIGN,
|
||||
SLACK_ACTION_DONE,
|
||||
SLACK_ACTION_REOPEN,
|
||||
SLACK_ASSIGN_BLOCK,
|
||||
SLACK_ASSIGN_SELECT_ACTION,
|
||||
SLACK_VIEW_ASSIGN_CALLBACK,
|
||||
} from './providers/slack.constants';
|
||||
import { buildTaskDeepLink, escapeSlackText } from './utils';
|
||||
import type { SlackEphemeralReply, SlackInteractionPayload, SlackSlashCommandPayload } from './types.internal';
|
||||
|
||||
// Orchestrates inbound Slack actions: /task (create), and Done / Assign buttons.
|
||||
// Every path resolves the Slack user to a TaskView user (identity map) and enforces
|
||||
// that user's RBAC before touching anything.
|
||||
export class SlackInboundManager {
|
||||
private readonly repository = new MessagingRepository();
|
||||
private readonly slack = new SlackProvider();
|
||||
|
||||
async handleSlashCommand(payload: SlackSlashCommandPayload): Promise<SlackEphemeralReply> {
|
||||
const userId = await this.resolveUser(payload.user_id, payload.team_id);
|
||||
if (!userId) return this.ephemeral(this.linkPrompt());
|
||||
|
||||
const description = (payload.text ?? '').trim();
|
||||
if (!description) return this.ephemeral('Usage: /task <description>');
|
||||
|
||||
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: 'slack', channelId: payload.channel_id ?? '', externalTeamId: payload.team_id ?? null });
|
||||
if (goalIds.length === 0) return this.ephemeral('This channel is not linked to a TaskView project.');
|
||||
if (goalIds.length > 1) return this.ephemeral('This channel is linked to several projects — create the task in TaskView.');
|
||||
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return this.ephemeral('Could not resolve your TaskView account.');
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
|
||||
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
|
||||
return this.ephemeral('You do not have permission to create tasks in this project.');
|
||||
}
|
||||
|
||||
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description });
|
||||
if (!created?.[0]) return this.ephemeral('Failed to create the task.');
|
||||
return this.ephemeral(`✅ Task created: ${description}`);
|
||||
}
|
||||
|
||||
async handleInteraction(interaction: SlackInteractionPayload): Promise<object | void> {
|
||||
if (interaction.type === 'view_submission') return this.handleAssignSubmit(interaction);
|
||||
if (interaction.type === 'block_actions') return this.handleBlockAction(interaction);
|
||||
}
|
||||
|
||||
private async handleBlockAction(i: SlackInteractionPayload): Promise<void> {
|
||||
const action = i.actions?.[0];
|
||||
const taskId = Number(action?.value);
|
||||
if (!action?.action_id || !taskId) return;
|
||||
|
||||
const userId = await this.resolveUser(i.user?.id, i.team?.id);
|
||||
if (!userId) return this.ackEphemeral(i.response_url, this.linkPrompt());
|
||||
|
||||
if (action.action_id === SLACK_ACTION_DONE) return this.completeTask(i, userId, taskId);
|
||||
if (action.action_id === SLACK_ACTION_REOPEN) return this.reopenTask(i, userId, taskId);
|
||||
if (action.action_id === SLACK_ACTION_ASSIGN) return this.openAssignModal(i, userId, taskId);
|
||||
}
|
||||
|
||||
private async completeTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return;
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
|
||||
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
|
||||
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
}
|
||||
|
||||
await appUser.tasksManager.updateTask({ id: taskId, complete: true });
|
||||
// Show which task (description gated by content permission, like notifications) + a link.
|
||||
const label = checker.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) && task.description
|
||||
? task.description.slice(0, 200)
|
||||
: `#${taskId}`;
|
||||
const orgSlug = await this.repository.fetchGoalOrgSlug(task.goalId);
|
||||
const url = buildTaskDeepLink(orgSlug, task.goalId, taskId, task.goalListId ?? null);
|
||||
const text = url ? `✅ Task completed: <${url}|${escapeSlackText(label)}>` : `✅ Task completed: ${escapeSlackText(label)}`;
|
||||
await this.ackEphemeral(i.response_url, text);
|
||||
}
|
||||
|
||||
private async reopenTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return;
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
|
||||
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
|
||||
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
}
|
||||
|
||||
await appUser.tasksManager.updateTask({ id: taskId, complete: false });
|
||||
await this.ackEphemeral(i.response_url, '↩️ Task reopened');
|
||||
}
|
||||
|
||||
private async openAssignModal(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return;
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
|
||||
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
|
||||
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
|
||||
}
|
||||
|
||||
const members = await this.repository.fetchGoalCollabMembers(task.goalId);
|
||||
if (members.length === 0) return this.ackEphemeral(i.response_url, 'This project has no members to assign.');
|
||||
|
||||
const botToken = await this.botTokenForTeam(i.team?.id);
|
||||
if (!botToken || !i.trigger_id) return this.ackEphemeral(i.response_url, 'Slack workspace is not fully connected.');
|
||||
|
||||
// Pre-select the current assignees so the modal shows who's assigned and lets the
|
||||
// user add or remove — submit sets the whole list.
|
||||
const currentAssignees = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
|
||||
const metadata = JSON.stringify({ taskId });
|
||||
await this.slack.openModal({ botToken, triggerId: i.trigger_id, view: this.assignView(members, currentAssignees, metadata) });
|
||||
}
|
||||
|
||||
private async handleAssignSubmit(i: SlackInteractionPayload): Promise<object> {
|
||||
if (i.view?.callback_id !== SLACK_VIEW_ASSIGN_CALLBACK) return {};
|
||||
const meta = this.parseMeta(i.view.private_metadata);
|
||||
const taskId = Number(meta.taskId);
|
||||
if (!taskId) return {};
|
||||
|
||||
const selected = i.view.state?.values?.[SLACK_ASSIGN_BLOCK]?.[SLACK_ASSIGN_SELECT_ACTION]?.selected_options ?? [];
|
||||
const selectedCollabIds = selected.map((o) => Number(o.value)).filter((n) => Number.isInteger(n) && n > 0);
|
||||
|
||||
const userId = await this.resolveUser(i.user?.id, i.team?.id);
|
||||
if (!userId) return this.viewError(this.linkPrompt());
|
||||
const appUser = await this.buildAppUser(userId);
|
||||
if (!appUser) return {};
|
||||
|
||||
const task = await appUser.tasksManager.fetchTaskById({ taskId });
|
||||
if (!task) return {};
|
||||
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
|
||||
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
|
||||
return this.viewError('You do not have permission to assign this task.');
|
||||
}
|
||||
|
||||
// toggleTaskUsers is called directly (not via the HTTP CanUpdateTaskAssignee guard),
|
||||
// so re-validate every selected id is a member of this goal — Slack doesn't guarantee
|
||||
// the submitted values are among the options we offered.
|
||||
const valid = await this.repository.filterCollabIdsInGoal(selectedCollabIds, task.goalId);
|
||||
if (valid.length !== selectedCollabIds.length) return this.viewError('One of the selected users is not a member of this project.');
|
||||
|
||||
// SET the assignee list to exactly the selection — this both adds and removes.
|
||||
// An empty selection clears everyone (the input block is optional).
|
||||
await appUser.tasksManager.toggleTaskUsers({ taskId, userIds: selectedCollabIds });
|
||||
return {}; // closes the modal
|
||||
}
|
||||
|
||||
private async resolveUser(slackUserId?: string, teamId?: string): Promise<number | null> {
|
||||
if (!slackUserId) return null;
|
||||
// Scope by workspace: a Slack user id is unique only within its team.
|
||||
return this.repository.findUserIdByExternalId({ provider: 'slack', externalUserId: slackUserId, externalTeamId: teamId ?? null });
|
||||
}
|
||||
|
||||
private async buildAppUser(userId: number): Promise<AppUser | null> {
|
||||
const record = await new AppUser().authManager.repository.fetchUserById(userId);
|
||||
if (!record || record.block !== 0) return null;
|
||||
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
|
||||
}
|
||||
|
||||
private async botTokenForTeam(teamId?: string): Promise<string | null> {
|
||||
if (!teamId) return null;
|
||||
const encrypted = await this.repository.fetchSlackBotTokenEncrypted(teamId);
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private assignView(members: { collabId: number; email: string }[], currentAssignees: number[], privateMetadata: string): object {
|
||||
const current = new Set(currentAssignees);
|
||||
const options = members.slice(0, 100).map((m) => ({
|
||||
text: { type: 'plain_text', text: m.email.slice(0, 75) },
|
||||
value: String(m.collabId),
|
||||
}));
|
||||
const initialOptions = options.filter((o) => current.has(Number(o.value)));
|
||||
|
||||
const element: Record<string, unknown> = {
|
||||
type: 'multi_static_select',
|
||||
action_id: SLACK_ASSIGN_SELECT_ACTION,
|
||||
placeholder: { type: 'plain_text', text: 'Select assignees' },
|
||||
options,
|
||||
};
|
||||
// Slack rejects an empty initial_options array — only set it when there are current ones.
|
||||
if (initialOptions.length > 0) element.initial_options = initialOptions;
|
||||
|
||||
return {
|
||||
type: 'modal',
|
||||
callback_id: SLACK_VIEW_ASSIGN_CALLBACK,
|
||||
private_metadata: privateMetadata,
|
||||
title: { type: 'plain_text', text: 'Assignees' },
|
||||
submit: { type: 'plain_text', text: 'Save' },
|
||||
close: { type: 'plain_text', text: 'Cancel' },
|
||||
blocks: [
|
||||
{
|
||||
type: 'input',
|
||||
block_id: SLACK_ASSIGN_BLOCK,
|
||||
optional: true, // allow clearing everyone
|
||||
label: { type: 'plain_text', text: 'Assignees' },
|
||||
element,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private parseMeta(raw?: string): { taskId?: number } {
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private viewError(message: string): object {
|
||||
return { response_action: 'errors', errors: { [SLACK_ASSIGN_BLOCK]: message } };
|
||||
}
|
||||
|
||||
private async ackEphemeral(responseUrl: string | undefined, text: string): Promise<void> {
|
||||
if (responseUrl) await this.slack.respondEphemeral(responseUrl, text);
|
||||
}
|
||||
|
||||
private ephemeral(text: string): SlackEphemeralReply {
|
||||
return { response_type: 'ephemeral', text };
|
||||
}
|
||||
|
||||
private linkPrompt(): string {
|
||||
return `Link your Slack account in TaskView first: ${process.env.APP_URL ?? ''}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Configuration constants for the messaging module. Endpoints are env-overridable
|
||||
// (a mock server in tests, or an enterprise proxy), defaulting to the public provider
|
||||
// URLs. The Slack Web API methods all derive from one base.
|
||||
|
||||
const SLACK_API_BASE = process.env.SLACK_API_BASE_URL || 'https://slack.com/api';
|
||||
|
||||
export const SLACK_AUTHORIZE_URL = process.env.SLACK_AUTHORIZE_URL || 'https://slack.com/oauth/v2/authorize';
|
||||
export const SLACK_WEBHOOK_PREFIX = process.env.SLACK_WEBHOOK_PREFIX || 'https://hooks.slack.com/';
|
||||
export const SLACK_TOKEN_URL = `${SLACK_API_BASE}/oauth.v2.access`;
|
||||
export const SLACK_POST_MESSAGE_URL = `${SLACK_API_BASE}/chat.postMessage`;
|
||||
export const SLACK_UPDATE_MESSAGE_URL = `${SLACK_API_BASE}/chat.update`;
|
||||
export const SLACK_VIEWS_OPEN_URL = `${SLACK_API_BASE}/views.open`;
|
||||
|
||||
export const SLACK_OAUTH_NONCE_COOKIE = 'msg_slack_oauth_nonce';
|
||||
export const OAUTH_STATE_TTL = '10m';
|
||||
export const OAUTH_NONCE_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export const TELEGRAM_API = 'https://api.telegram.org';
|
||||
|
||||
// Lifetime of a deep-link binding token (connect-link flow).
|
||||
export const LINK_TTL_MS = 15 * 60 * 1000;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BuildMessagingMessageArgs, MessagingEvent, MessagingMessage } from './types';
|
||||
|
||||
type Audience = 'personal' | 'project';
|
||||
|
||||
// Delivered message titles. English on the server, matching NotificationMessages —
|
||||
// the backend has no i18n/per-recipient locale, so notification text is English by
|
||||
// convention. (The event-selection UI is localized separately via frontend i18n.)
|
||||
// Personal wording is second-person where it differs from the project-channel wording.
|
||||
const TITLES: Record<MessagingEvent, { personal: string; project: string }> = {
|
||||
'task.created': { personal: '[New task]', project: '[New task]' },
|
||||
'task.assigned': { personal: '[Task assigned to you]', project: '[Task assigned]' },
|
||||
'task.statusChanged': { personal: '[Task status changed]', project: '[Task status changed]' },
|
||||
'task.completed': { personal: '[Task completed]', project: '[Task completed]' },
|
||||
'task.edited': { personal: '[Task edited]', project: '[Task edited]' },
|
||||
'task.addedToSprint': { personal: '[Task added to sprint]', project: '[Task added to sprint]' },
|
||||
'task.deleted': { personal: '[Task deleted]', project: '[Task deleted]' },
|
||||
'sprint.created': { personal: '[Sprint created]', project: '[Sprint created]' },
|
||||
'sprint.updated': { personal: '[Sprint edited]', project: '[Sprint edited]' },
|
||||
'sprint.started': { personal: '[Sprint started]', project: '[Sprint started]' },
|
||||
'sprint.reviewStarted': { personal: '[Sprint review started]', project: '[Sprint review started]' },
|
||||
'sprint.completed': { personal: '[Sprint completed]', project: '[Sprint completed]' },
|
||||
'sprint.paused': { personal: '[Sprint paused]', project: '[Sprint paused]' },
|
||||
'sprint.resumed': { personal: '[Sprint resumed]', project: '[Sprint resumed]' },
|
||||
'sprint.deleted': { personal: '[Sprint deleted]', project: '[Sprint deleted]' },
|
||||
'member.added': { personal: '[Member added]', project: '[Member added]' },
|
||||
'member.removed': { personal: '[Member removed]', project: '[Member removed]' },
|
||||
'member.rolesChanged': { personal: '[Member roles changed]', project: '[Member roles changed]' },
|
||||
'time.started': { personal: '[Timer started]', project: '[Timer started]' },
|
||||
'time.stopped': { personal: '[Timer stopped]', project: '[Timer stopped]' },
|
||||
'time.logged': { personal: '[Time logged]', project: '[Time logged]' },
|
||||
'time.updated': { personal: '[Time entry edited]', project: '[Time entry edited]' },
|
||||
'time.deleted': { personal: '[Time entry deleted]', project: '[Time entry deleted]' },
|
||||
'recurrence.created': { personal: '[Recurrence created]', project: '[Recurrence created]' },
|
||||
'recurrence.updated': { personal: '[Recurrence edited]', project: '[Recurrence edited]' },
|
||||
'recurrence.paused': { personal: '[Recurrence paused]', project: '[Recurrence paused]' },
|
||||
'recurrence.resumed': { personal: '[Recurrence resumed]', project: '[Recurrence resumed]' },
|
||||
'recurrence.ended': { personal: '[Recurrence ended]', project: '[Recurrence ended]' },
|
||||
'recurrence.deleted': { personal: '[Recurrence deleted]', project: '[Recurrence deleted]' },
|
||||
'recurrence.skipped': { personal: '[Occurrence skipped]', project: '[Occurrence skipped]' },
|
||||
};
|
||||
|
||||
export function buildMessagingMessage(args: BuildMessagingMessageArgs): MessagingMessage {
|
||||
const base = args.titleOverride ?? TITLES[args.event][args.audience];
|
||||
const title = args.projectName ? `${base} [${args.projectName}]` : base;
|
||||
return { event: args.event, title, body: args.body, url: args.url, taskId: args.taskId, footer: args.footer, completed: args.completed, actions: args.actions };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions, type GoalPermissionType } from '../../../types/auth.types';
|
||||
|
||||
function requireProjectPermission(permission: GoalPermissionType) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = Number(req.body?.goalId ?? req.query?.goalId);
|
||||
if (!goalId || Number.isNaN(goalId)) return res.status(400).end();
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (!checker.hasPermissions(permission)) return res.status(403).end();
|
||||
|
||||
res.locals.messagingGoalId = goalId;
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export const CanManageProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_MANAGE);
|
||||
export const CanViewProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_VIEW);
|
||||
|
||||
export function getAuthorizedGoalId(res: Response): number {
|
||||
return res.locals.messagingGoalId as number;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
const MAX_AGE_SECONDS = 300;
|
||||
|
||||
// Verifies inbound Slack requests (slash commands, interactivity) via the app Signing
|
||||
// Secret: HMAC-SHA256 over `v0:${timestamp}:${rawBody}`, compared timing-safe against the
|
||||
// X-Slack-Signature header, with a 5-minute timestamp window to blunt replay.
|
||||
export const VerifySlackRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||
const secret = process.env.SLACK_SIGNING_SECRET;
|
||||
if (!secret) {
|
||||
$logger.error('[Messaging/Slack] SLACK_SIGNING_SECRET is not set — rejecting inbound request');
|
||||
return res.status(503).end();
|
||||
}
|
||||
|
||||
const signature = req.headers['x-slack-signature'] as string | undefined;
|
||||
const timestamp = req.headers['x-slack-request-timestamp'] as string | undefined;
|
||||
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody;
|
||||
if (!signature || !timestamp || !rawBody) return res.status(401).end();
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) return res.status(401).end();
|
||||
|
||||
const expected = `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${rawBody.toString('utf8')}`).digest('hex')}`;
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
const signatureBuf = Buffer.from(signature);
|
||||
if (expectedBuf.length !== signatureBuf.length || !timingSafeEqual(expectedBuf, signatureBuf)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
/**
|
||||
* Telegram sends the value configured via setWebhook's secret_token in this
|
||||
* header on every update. Reject anything that doesn't match so the public
|
||||
* inbound endpoint can't be spoofed.
|
||||
*/
|
||||
export function VerifyTelegramWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
const expected = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
$logger.warn('[Messaging/Telegram] webhook rejected: TELEGRAM_WEBHOOK_SECRET not set');
|
||||
return res.status(503).end();
|
||||
}
|
||||
|
||||
const provided = req.header('X-Telegram-Bot-Api-Secret-Token') ?? '';
|
||||
if (!safeEqual(provided, expected)) {
|
||||
$logger.warn({ hasHeader: !!req.header('X-Telegram-Bot-Api-Secret-Token') }, '[Messaging/Telegram] webhook rejected: secret mismatch');
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a);
|
||||
const bBuf = Buffer.from(b);
|
||||
if (aBuf.length !== bBuf.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(aBuf, bBuf);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingProviderId } from '../types';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw } from '../types.internal';
|
||||
|
||||
/**
|
||||
* Full contract for a messaging provider (Telegram, Slack, …). A provider owns its
|
||||
* protocol end-to-end — building connect URLs, exchanging OAuth codes, parsing inbound
|
||||
* payloads, calling the messenger API. It returns normalized data; the manager does the
|
||||
* business work (persistence, RBAC) and never branches on the concrete provider.
|
||||
*/
|
||||
export interface MessagingProvider {
|
||||
readonly id: MessagingProviderId;
|
||||
|
||||
/** Whether this instance has the credentials needed to operate (env/admin config). */
|
||||
isConfigured(): boolean;
|
||||
|
||||
/** Send one message to a chat/channel. Never throws — failures come back as a result. */
|
||||
deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult>;
|
||||
|
||||
/**
|
||||
* Everything needed to start connecting this owner: an authorize/deep-link URL, plus
|
||||
* optionally a token to persist (deep-link flows) or a cookie to set (OAuth anti-CSRF).
|
||||
*/
|
||||
startConnect(ctx: ConnectContext): Promise<ConnectStart>;
|
||||
|
||||
/** Parse a raw inbound payload (webhook / OAuth callback) into a normalized intent, or null. */
|
||||
parseInbound(raw: InboundRaw): Promise<InboundIntent | null>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Slack Block Kit action / callback identifiers, shared between the provider (which
|
||||
// renders the buttons/modal) and the inbound handler (which routes interactions by them).
|
||||
export const SLACK_ACTION_DONE = 'tv_task_done';
|
||||
export const SLACK_ACTION_REOPEN = 'tv_task_reopen';
|
||||
export const SLACK_ACTION_ASSIGN = 'tv_task_assign';
|
||||
export const SLACK_VIEW_ASSIGN_CALLBACK = 'tv_assign_submit';
|
||||
export const SLACK_ASSIGN_BLOCK = 'tv_assign_block';
|
||||
export const SLACK_ASSIGN_SELECT_ACTION = 'tv_assign_select';
|
||||
@@ -0,0 +1,290 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { encrypt } from '../../../utils/crypto';
|
||||
import type { MessagingProvider } from './MessagingProvider';
|
||||
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
|
||||
import type { MessagingOwnerType } from '../types';
|
||||
import { SLACK_ACTION_ASSIGN, SLACK_ACTION_DONE, SLACK_ACTION_REOPEN } from './slack.constants';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingOAuthState, SlackOAuthAccessResponse, SlackOAuthExchange, SlackOpenModalArgs } from '../types.internal';
|
||||
import {
|
||||
OAUTH_NONCE_MAX_AGE_MS,
|
||||
OAUTH_STATE_TTL,
|
||||
SLACK_AUTHORIZE_URL,
|
||||
SLACK_OAUTH_NONCE_COOKIE,
|
||||
SLACK_POST_MESSAGE_URL,
|
||||
SLACK_TOKEN_URL,
|
||||
SLACK_VIEWS_OPEN_URL,
|
||||
SLACK_WEBHOOK_PREFIX,
|
||||
} from '../config';
|
||||
import { escapeSlackText, isSafeUrl } from '../utils';
|
||||
|
||||
/**
|
||||
* Slack provider. Bot credentials are instance-level (env), like GitHub/GitLab.
|
||||
* Personal connections DM the installing user (bot token + chat.postMessage);
|
||||
* project connections use an incoming webhook (channel chosen during install).
|
||||
*/
|
||||
export class SlackProvider implements MessagingProvider {
|
||||
readonly id: MessagingProviderId = 'slack';
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!process.env.SLACK_CLIENT_ID && !!process.env.SLACK_CLIENT_SECRET && !!process.env.SLACK_CALLBACK_URL;
|
||||
}
|
||||
|
||||
getOAuthUrl(state: string, ownerType: MessagingOwnerType): string {
|
||||
const clientId = process.env.SLACK_CLIENT_ID;
|
||||
const redirectUri = process.env.SLACK_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('Slack integration OAuth is not configured');
|
||||
}
|
||||
// Personal → bot posts a DM to the installer. Project → channel is picked at
|
||||
// install via incoming-webhook, and chat:write lets us post + update messages
|
||||
// (chat.update) and open modals for the interactive buttons via the bot token.
|
||||
const scope = ownerType === 'project' ? 'incoming-webhook,chat:write' : 'chat:write';
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
scope,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
});
|
||||
return `${SLACK_AUTHORIZE_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<SlackOAuthExchange> {
|
||||
const redirectUri = process.env.SLACK_CALLBACK_URL;
|
||||
const res = await axios.post<SlackOAuthAccessResponse>(
|
||||
SLACK_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
client_id: process.env.SLACK_CLIENT_ID ?? '',
|
||||
client_secret: process.env.SLACK_CLIENT_SECRET ?? '',
|
||||
code,
|
||||
redirect_uri: redirectUri ?? '',
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
);
|
||||
|
||||
const data = res.data;
|
||||
if (!data.ok || !data.access_token) {
|
||||
throw new Error(`Slack code exchange failed: ${data.error ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
return {
|
||||
botToken: data.access_token,
|
||||
teamId: data.team?.id ?? null,
|
||||
teamName: data.team?.name ?? null,
|
||||
authedUserId: data.authed_user?.id ?? null,
|
||||
webhookUrl: data.incoming_webhook?.url ?? null,
|
||||
webhookChannel: data.incoming_webhook?.channel ?? null,
|
||||
webhookChannelId: data.incoming_webhook?.channel_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
|
||||
// Anti-CSRF: a nonce is set as a cookie on the initiating browser and its hash is
|
||||
// carried inside the signed state, verified on callback.
|
||||
const nonce = randomBytes(32).toString('hex');
|
||||
const state = jwt.sign(
|
||||
{
|
||||
provider: 'slack',
|
||||
ownerType: ctx.ownerType,
|
||||
ownerId: ctx.ownerId,
|
||||
userId: ctx.userId,
|
||||
nonceHash: this.hashNonce(nonce),
|
||||
returnPath: ctx.returnPath ?? '',
|
||||
} as MessagingOAuthState,
|
||||
process.env.JWT_SIGN as string,
|
||||
{ expiresIn: OAUTH_STATE_TTL },
|
||||
);
|
||||
return {
|
||||
kind: 'oauth',
|
||||
url: this.getOAuthUrl(state, ctx.ownerType),
|
||||
setCookie: { name: SLACK_OAUTH_NONCE_COOKIE, value: nonce, maxAgeMs: OAUTH_NONCE_MAX_AGE_MS },
|
||||
};
|
||||
}
|
||||
|
||||
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
|
||||
// Slash commands / interactivity are handled by SlackInboundManager (they need
|
||||
// multi-step Slack UI). Here we only complete the OAuth connect round-trip.
|
||||
if (raw.source !== 'oauth-callback') return null;
|
||||
const { code, state } = (raw.payload as { code?: string; state?: string }) ?? {};
|
||||
if (!code || !state) return null;
|
||||
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as MessagingOAuthState;
|
||||
if (payload.provider !== 'slack') throw new Error('Provider mismatch in OAuth state');
|
||||
|
||||
// The state's nonce hash must match the cookie set on the initiating browser.
|
||||
// Enforced only in production: local dev often splits the frontend (localhost) from
|
||||
// the public callback (tunnel), where a single browser cookie can't bridge origins.
|
||||
const nonceOk = !!raw.cookie && this.hashNonce(raw.cookie) === payload.nonceHash;
|
||||
if (!nonceOk) {
|
||||
if (process.env.NODE_ENV === 'production') throw new Error('OAuth state/nonce mismatch');
|
||||
$logger.warn('[Messaging/Slack] Skipping OAuth nonce check (non-production; split-origin dev)');
|
||||
}
|
||||
|
||||
const ex = await this.exchangeCode(code);
|
||||
|
||||
if (payload.ownerType === 'project') {
|
||||
if (!ex.webhookChannelId) throw new Error('Slack did not return the chosen channel');
|
||||
// Store the bot token (not the webhook URL): posting via chat.postMessage also
|
||||
// lets us update messages and open modals for the interactive buttons.
|
||||
return {
|
||||
kind: 'createConnection',
|
||||
redirect: payload.returnPath,
|
||||
connection: {
|
||||
provider: 'slack',
|
||||
ownerType: 'project',
|
||||
ownerId: payload.ownerId,
|
||||
targetChatId: ex.webhookChannelId,
|
||||
title: ex.webhookChannel,
|
||||
externalTeamId: ex.teamId,
|
||||
accessTokenEncrypted: encrypt(ex.botToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!ex.authedUserId) throw new Error('Slack did not return the installing user');
|
||||
return {
|
||||
kind: 'createConnection',
|
||||
redirect: payload.returnPath,
|
||||
identity: { userId: payload.userId, externalUserId: ex.authedUserId, externalTeamId: ex.teamId ?? null },
|
||||
connection: {
|
||||
provider: 'slack',
|
||||
ownerType: 'user',
|
||||
ownerId: payload.userId,
|
||||
targetChatId: ex.authedUserId,
|
||||
title: ex.teamName ? `Slack (${ex.teamName})` : 'Slack',
|
||||
externalTeamId: ex.teamId,
|
||||
accessTokenEncrypted: encrypt(ex.botToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private hashNonce(nonce: string): string {
|
||||
return createHash('sha256').update(nonce).digest('hex');
|
||||
}
|
||||
|
||||
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
|
||||
if (!args.accessToken) return { success: false };
|
||||
const secret = args.accessToken;
|
||||
const text = this.render(args.message);
|
||||
const blocks = this.buildTaskBlocks(args.message);
|
||||
|
||||
try {
|
||||
// Legacy project connections store a webhook URL (no bot token → no interactive
|
||||
// buttons). Newer connections store a bot token and post via chat.postMessage.
|
||||
if (secret.startsWith(SLACK_WEBHOOK_PREFIX)) {
|
||||
const res = await axios.post(secret, { text, blocks }, { timeout: 10000 });
|
||||
return { success: res.status === 200 };
|
||||
}
|
||||
|
||||
const res = await axios.post<{ ok: boolean; error?: string }>(
|
||||
SLACK_POST_MESSAGE_URL,
|
||||
{ channel: args.chatId, text, blocks },
|
||||
{ headers: { Authorization: `Bearer ${secret}` }, timeout: 10000 },
|
||||
);
|
||||
if (!res.data.ok) {
|
||||
$logger.warn(`[Messaging/Slack] chat.postMessage failed: ${res.data.error ?? 'unknown'} (channel=${args.chatId})`);
|
||||
}
|
||||
return { success: res.data.ok, errorCode: res.data.ok ? undefined : 400 };
|
||||
} catch (err) {
|
||||
// Never log the raw axios error — its config.url (incoming webhook, itself a
|
||||
// secret) / config.headers.Authorization (bot token) would land in disk logs.
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
$logger.error({ status }, `[Messaging/Slack] Delivery failed to ${args.chatId}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a modal (assignee picker). trigger_id from the interaction expires in ~3s. */
|
||||
async openModal(args: SlackOpenModalArgs): Promise<boolean> {
|
||||
return this.callApi(SLACK_VIEWS_OPEN_URL, args.botToken, { trigger_id: args.triggerId, view: args.view });
|
||||
}
|
||||
|
||||
/** Ephemeral feedback for an interaction — posts to the payload's response_url (no token). */
|
||||
async respondEphemeral(responseUrl: string, text: string): Promise<void> {
|
||||
// response_url is Slack-supplied; pin its host to the configured webhook host so a
|
||||
// trust regression can never turn this into an SSRF to an arbitrary URL.
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(responseUrl).host;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (host !== new URL(SLACK_WEBHOOK_PREFIX).host) return;
|
||||
try {
|
||||
// replace_original:false — post a separate ephemeral note, never overwrite the card
|
||||
// the button was on (the response_url default is to replace the original message).
|
||||
await axios.post(responseUrl, { response_type: 'ephemeral', replace_original: false, text }, { timeout: 10000 });
|
||||
} catch {
|
||||
// Best-effort user feedback; nothing to recover if Slack's response_url is unreachable.
|
||||
}
|
||||
}
|
||||
|
||||
private async callApi(url: string, botToken: string, body: Record<string, unknown>): Promise<boolean> {
|
||||
try {
|
||||
const res = await axios.post<{ ok: boolean; error?: string }>(url, body, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
timeout: 10000,
|
||||
});
|
||||
if (!res.data.ok) $logger.warn(`[Messaging/Slack] ${url.split('/').pop()} failed: ${res.data.error ?? 'unknown'}`);
|
||||
return res.data.ok;
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
$logger.error({ status }, `[Messaging/Slack] API call failed: ${url.split('/').pop()}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Block Kit: a text section plus Done/Assign action buttons for task events. The button
|
||||
// value carries the taskId so the interaction round-trips it back to us.
|
||||
private buildTaskBlocks(message: MessagingMessage): unknown[] {
|
||||
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
|
||||
const blocks: unknown[] = [];
|
||||
|
||||
// Task name as a large header (Slack's biggest text). Header is plain_text only — no
|
||||
// link/markdown — so the clickable label lives in the section below.
|
||||
if (message.body) {
|
||||
blocks.push({ type: 'header', text: { type: 'plain_text', text: message.body.slice(0, 150), emoji: true } });
|
||||
}
|
||||
|
||||
// Event + project label; the whole line links to the task.
|
||||
const label = escapeSlackText(message.title);
|
||||
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: url ? `*<${url}|${label}>*` : `*${label}*` } });
|
||||
// Assignees line above the buttons so the actions sit at the very bottom.
|
||||
if (message.footer) {
|
||||
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: escapeSlackText(message.footer) }] });
|
||||
}
|
||||
if (message.taskId && message.actions !== false) {
|
||||
// A completed task offers Reopen; an open task offers Done.
|
||||
const primary = message.completed
|
||||
? { type: 'button', text: { type: 'plain_text', text: '↩️ Reopen' }, action_id: SLACK_ACTION_REOPEN, value: String(message.taskId) }
|
||||
: { type: 'button', text: { type: 'plain_text', text: '✅ Done' }, action_id: SLACK_ACTION_DONE, value: String(message.taskId) };
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
primary,
|
||||
{ type: 'button', text: { type: 'plain_text', text: '👤 Assign' }, action_id: SLACK_ACTION_ASSIGN, value: String(message.taskId) },
|
||||
],
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private render(message: MessagingMessage): string {
|
||||
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
|
||||
const lines: string[] = [];
|
||||
if (url) {
|
||||
// Slack link syntax <url|text>: hyperlink the description, or the title
|
||||
// when there is no description.
|
||||
lines.push(message.body
|
||||
? `*${escapeSlackText(message.title)}*\n<${url}|${escapeSlackText(message.body)}>`
|
||||
: `*<${url}|${escapeSlackText(message.title)}>*`);
|
||||
} else {
|
||||
lines.push(`*${escapeSlackText(message.title)}*`);
|
||||
if (message.body) lines.push(escapeSlackText(message.body));
|
||||
}
|
||||
if (message.footer) lines.push(escapeSlackText(message.footer));
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import type { MessagingProvider } from './MessagingProvider';
|
||||
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
|
||||
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, TelegramInboundMessage } from '../types.internal';
|
||||
import { LINK_TTL_MS, TELEGRAM_API } from '../config';
|
||||
import { escapeHtml, isSafeUrl } from '../utils';
|
||||
|
||||
/**
|
||||
* Telegram bot provider. The bot token is instance-level (env), so the SaaS
|
||||
* ships an official bot and self-hosted installs supply their own via
|
||||
* TELEGRAM_BOT_TOKEN — no code fork, only config.
|
||||
*/
|
||||
export class TelegramProvider implements MessagingProvider {
|
||||
readonly id: MessagingProviderId = 'telegram';
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!process.env.TELEGRAM_BOT_TOKEN && !!process.env.TELEGRAM_BOT_USERNAME;
|
||||
}
|
||||
|
||||
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) return { success: false };
|
||||
|
||||
try {
|
||||
const response = await fetch(`${TELEGRAM_API}/bot${token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: args.chatId,
|
||||
text: this.render(args.message),
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return { success: response.ok, errorCode: response.ok ? undefined : response.status };
|
||||
} catch (err) {
|
||||
// Log only the message — the raw error can carry the request URL, which
|
||||
// embeds the bot token.
|
||||
const message = err instanceof Error ? err.message : 'unknown error';
|
||||
$logger.error({ message }, `[Messaging/Telegram] Delivery failed to chat=${args.chatId}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
|
||||
const username = process.env.TELEGRAM_BOT_USERNAME;
|
||||
const token = randomBytes(24).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
|
||||
// Personal → deep-link into a private chat (auto-sends /start <token>).
|
||||
// Project → startgroup lets the admin add the bot to a group; the bind completes
|
||||
// when they run /connect <token> there.
|
||||
const url = ctx.ownerType === 'user'
|
||||
? `https://t.me/${username}?start=${token}`
|
||||
: `https://t.me/${username}?startgroup=${token}`;
|
||||
return { kind: 'deep-link', url, persistToken: { token, expiresAt } };
|
||||
}
|
||||
|
||||
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
|
||||
if (raw.source !== 'webhook') return null;
|
||||
const message = (raw.payload as { message?: TelegramInboundMessage })?.message;
|
||||
if (!message) return null;
|
||||
|
||||
const text = typeof message.text === 'string' ? message.text.trim() : '';
|
||||
const chatId = message.chat?.id;
|
||||
const chatType = message.chat?.type;
|
||||
const fromId = message.from?.id;
|
||||
if (!text || text.length > 4096 || typeof chatId !== 'number' || typeof fromId !== 'number') return null;
|
||||
|
||||
// /task <description> — create a task in this chat's project.
|
||||
const taskMatch = text.match(/^\/task(?:@\w+)?\s+([\s\S]+)$/);
|
||||
if (taskMatch) {
|
||||
return { kind: 'command', command: 'createTask', text: taskMatch[1].trim().slice(0, 2000), chatId: String(chatId), externalUserId: String(fromId) };
|
||||
}
|
||||
|
||||
const match = text.match(/^\/(?:start|connect)(?:@\w+)?\s+(\S+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
if (chatType === 'private') {
|
||||
const title = typeof message.from?.username === 'string' ? `@${message.from.username}` : null;
|
||||
return { kind: 'bindByToken', token: match[1], scope: 'user', chatId: String(chatId), externalUserId: String(fromId), title };
|
||||
}
|
||||
if (chatType === 'group' || chatType === 'supergroup') {
|
||||
const title = typeof message.chat?.title === 'string' ? message.chat.title : null;
|
||||
return { kind: 'bindByToken', token: match[1], scope: 'project', chatId: String(chatId), externalUserId: String(fromId), title };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private render(message: MessagingMessage): string {
|
||||
// Mirror the Slack layout: task name (bold) on top, the "[event] [project]" label as
|
||||
// the link below, assignees last. Telegram has no header size, so name is just bold.
|
||||
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (message.body) lines.push(`<b>${escapeHtml(message.body)}</b>`);
|
||||
|
||||
const label = escapeHtml(message.title);
|
||||
lines.push(url ? `<a href="${escapeHtml(url)}">${label}</a>` : label);
|
||||
|
||||
if (message.footer) lines.push(escapeHtml(message.footer));
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { MessagingEvent, MessagingOwnerType, MessagingProviderId } from './types';
|
||||
|
||||
export type MessagingConnectionForClient = Omit<MessagingConnectionsSchemaTypeForSelect, 'accessTokenEncrypted'>;
|
||||
|
||||
export interface MessagingRecipient {
|
||||
userId: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SlackOpenModalArgs {
|
||||
botToken: string;
|
||||
triggerId: string;
|
||||
view: unknown;
|
||||
}
|
||||
|
||||
export interface SlackOAuthAccessResponse {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
access_token?: string;
|
||||
team?: { id?: string; name?: string };
|
||||
authed_user?: { id?: string };
|
||||
incoming_webhook?: { url?: string; channel?: string; channel_id?: string };
|
||||
}
|
||||
|
||||
export interface SlackSlashCommandPayload {
|
||||
user_id?: string;
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface SlackInteractionPayload {
|
||||
type?: string;
|
||||
user?: { id?: string };
|
||||
team?: { id?: string };
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
channel?: { id?: string };
|
||||
container?: { message_ts?: string };
|
||||
message?: { ts?: string; blocks?: { type?: string; text?: { text?: string } }[] };
|
||||
actions?: { action_id?: string; value?: string }[];
|
||||
view?: {
|
||||
callback_id?: string;
|
||||
private_metadata?: string;
|
||||
state?: { values?: Record<string, Record<string, { selected_options?: { value?: string }[] }>> };
|
||||
};
|
||||
}
|
||||
|
||||
export interface SlackEphemeralReply {
|
||||
response_type: 'ephemeral';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TelegramInboundMessage {
|
||||
text?: unknown;
|
||||
chat?: { id?: unknown; type?: unknown; title?: unknown };
|
||||
from?: { id?: unknown; username?: unknown };
|
||||
}
|
||||
|
||||
export interface MessagingTaskContext {
|
||||
id: number;
|
||||
goalListId: number | null;
|
||||
description: string | null;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface MessagingDispatchArgs {
|
||||
event: MessagingEvent;
|
||||
goalId: number;
|
||||
personalRecipients: MessagingRecipient[];
|
||||
initiatorId: number | null;
|
||||
/** Simple, non-content body (sprint name, member email, …). */
|
||||
body?: string;
|
||||
/** Task context: description is RBAC-gated per recipient (COMPONENT_CAN_WATCH_CONTENT) + a task deep-link. */
|
||||
task?: MessagingTaskContext;
|
||||
/** Overrides the title (e.g. "[Task reopened]" while still gated by the task.completed subscription). */
|
||||
titleOverride?: string;
|
||||
}
|
||||
|
||||
/** Slack identities are keyed by workspace; Telegram passes externalTeamId = null. */
|
||||
export interface MessagingIdentityUpsert {
|
||||
userId: number;
|
||||
provider: string;
|
||||
externalUserId: string;
|
||||
externalTeamId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingIdentityLookup {
|
||||
provider: string;
|
||||
externalUserId: string;
|
||||
externalTeamId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingChannelLookup {
|
||||
provider: string;
|
||||
channelId: string;
|
||||
externalTeamId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingConnectionCreate {
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
targetChatId: string;
|
||||
title: string | null;
|
||||
externalTeamId: string | null;
|
||||
accessTokenEncrypted: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingOwnedRef {
|
||||
id: number;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
}
|
||||
|
||||
export interface MessagingOwnedToggle extends MessagingOwnedRef {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface MessagingLinkTokenCreate {
|
||||
token: string;
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
createdBy: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface SlackOAuthExchange {
|
||||
botToken: string;
|
||||
teamId: string | null;
|
||||
teamName: string | null;
|
||||
authedUserId: string | null;
|
||||
webhookUrl: string | null;
|
||||
webhookChannel: string | null;
|
||||
webhookChannelId: string | null;
|
||||
}
|
||||
|
||||
export interface MessagingOAuthState {
|
||||
provider: MessagingProviderId;
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
userId: number;
|
||||
/** SHA-256 of a nonce also stored in an httpOnly cookie — binds the flow to the initiating browser (anti-CSRF). */
|
||||
nonceHash: string;
|
||||
/** In-app path the user started from, to return them there after the callback. */
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export interface MessagingConnectLinkResult {
|
||||
provider: MessagingProviderId;
|
||||
url: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
// ── Provider lifecycle (single MessagingProvider interface) ─────────────────
|
||||
// Everything a provider needs to start a connection. The manager stays generic:
|
||||
// it never branches on provider — it just persists what the provider returns.
|
||||
|
||||
export interface ConnectContext {
|
||||
ownerType: MessagingOwnerType;
|
||||
ownerId: number;
|
||||
userId: number;
|
||||
/** In-app path to return to after an OAuth round-trip (Slack); ignored by others. */
|
||||
returnPath?: string;
|
||||
}
|
||||
|
||||
// Discriminated so the two flows can't be mixed up: a deep-link provider always mints a
|
||||
// token to persist; an OAuth provider always sets an anti-CSRF cookie. Never both.
|
||||
export type ConnectStart =
|
||||
| { kind: 'deep-link'; url: string; persistToken: { token: string; expiresAt: Date } }
|
||||
| { kind: 'oauth'; url: string; setCookie: { name: string; value: string; maxAgeMs: number } };
|
||||
|
||||
/** Raw inbound, tagged by which endpoint received it so the provider can parse accordingly. */
|
||||
export interface InboundRaw {
|
||||
source: 'oauth-callback' | 'webhook';
|
||||
payload: unknown;
|
||||
/** Cookie value echoed back for verification (OAuth nonce). */
|
||||
cookie?: string;
|
||||
}
|
||||
|
||||
/** Normalized inbound outcome. The provider parses protocol; the manager does DB + RBAC. */
|
||||
export type InboundIntent =
|
||||
| {
|
||||
kind: 'createConnection';
|
||||
connection: MessagingConnectionCreate;
|
||||
/** Personal connections also link the messenger account to a TaskView user. */
|
||||
identity?: { userId: number; externalUserId: string; externalTeamId: string | null };
|
||||
/** In-app path to redirect the browser to (OAuth callback). */
|
||||
redirect?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'bindByToken';
|
||||
token: string;
|
||||
scope: MessagingOwnerType;
|
||||
chatId: string;
|
||||
externalUserId: string;
|
||||
title: string | null;
|
||||
}
|
||||
| {
|
||||
/** A command from a chat (e.g. Telegram /task). The manager resolves identity + RBAC. */
|
||||
kind: 'command';
|
||||
command: 'createTask';
|
||||
text: string;
|
||||
chatId: string;
|
||||
externalUserId: string;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const MESSAGING_PROVIDERS = ['telegram', 'slack'] as const;
|
||||
export type MessagingProviderId = typeof MESSAGING_PROVIDERS[number];
|
||||
|
||||
export const MESSAGING_OWNER_TYPES = ['user', 'project', 'organization'] as const;
|
||||
export type MessagingOwnerType = typeof MESSAGING_OWNER_TYPES[number];
|
||||
|
||||
/**
|
||||
* Provider-agnostic event kinds the module reacts to. They map onto EventBus
|
||||
* events in MessagingDispatcher and onto notification types for preferences.
|
||||
*/
|
||||
/**
|
||||
* User-selectable events. Task events target the task's assignees; sprint events
|
||||
* target project members. Each connection subscribes to the subset it wants.
|
||||
*/
|
||||
export const MESSAGING_EVENTS = [
|
||||
// tasks — audience: the task's assignees (task.deleted → project members)
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.statusChanged',
|
||||
'task.completed',
|
||||
'task.edited',
|
||||
'task.addedToSprint',
|
||||
'task.deleted',
|
||||
// sprints — audience: project members
|
||||
'sprint.created',
|
||||
'sprint.updated',
|
||||
'sprint.started',
|
||||
'sprint.reviewStarted',
|
||||
'sprint.completed',
|
||||
'sprint.paused',
|
||||
'sprint.resumed',
|
||||
'sprint.deleted',
|
||||
// members — audience: project members
|
||||
'member.added',
|
||||
'member.removed',
|
||||
'member.rolesChanged',
|
||||
// time tracking — audience: project members
|
||||
'time.started',
|
||||
'time.stopped',
|
||||
'time.logged',
|
||||
'time.updated',
|
||||
'time.deleted',
|
||||
// recurring rules — audience: project members
|
||||
'recurrence.created',
|
||||
'recurrence.updated',
|
||||
'recurrence.paused',
|
||||
'recurrence.resumed',
|
||||
'recurrence.ended',
|
||||
'recurrence.deleted',
|
||||
'recurrence.skipped',
|
||||
] as const;
|
||||
export type MessagingEvent = typeof MESSAGING_EVENTS[number];
|
||||
|
||||
export function sanitizeMessagingEvents(events: string[]): MessagingEvent[] {
|
||||
const allowed = new Set<string>(MESSAGING_EVENTS);
|
||||
return [...new Set(events.filter((e) => allowed.has(e)))] as MessagingEvent[];
|
||||
}
|
||||
|
||||
/** Normalized message a provider renders into its own format. */
|
||||
export interface MessagingMessage {
|
||||
event: MessagingEvent;
|
||||
title: string;
|
||||
body?: string;
|
||||
url?: string;
|
||||
/** Present for task events — lets the Slack provider attach Done/Assign action buttons. */
|
||||
taskId?: number;
|
||||
/** An extra line rendered at the bottom (e.g. assignees) — part of the original message. */
|
||||
footer?: string;
|
||||
/** Task's current completion state — the primary button is Reopen when true, else Done. */
|
||||
completed?: boolean;
|
||||
/** Whether to render interactive action buttons (Slack). Suppressed for content-hidden channels. */
|
||||
actions?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildMessagingMessageArgs {
|
||||
event: MessagingEvent;
|
||||
audience: 'personal' | 'project';
|
||||
body: string;
|
||||
url?: string;
|
||||
taskId?: number;
|
||||
projectName?: string | null;
|
||||
footer?: string;
|
||||
completed?: boolean;
|
||||
/** Overrides the title (keeps the subscription event but shows different wording, e.g. reopened). */
|
||||
titleOverride?: string;
|
||||
/** Whether to render interactive action buttons. Defaults to shown; false for content-hidden channels. */
|
||||
actions?: boolean;
|
||||
}
|
||||
|
||||
export interface MessagingDeliverArgs {
|
||||
chatId: string;
|
||||
/** Decrypted per-connection secret (Slack bot token / webhook URL); null for Telegram (instance bot token from env). */
|
||||
accessToken: string | null;
|
||||
message: MessagingMessage;
|
||||
}
|
||||
|
||||
export interface MessagingDeliverResult {
|
||||
success: boolean;
|
||||
errorCode?: number;
|
||||
}
|
||||
|
||||
export interface MessagingDeliverJobData {
|
||||
connectionId: number;
|
||||
provider: MessagingProviderId;
|
||||
chatId: string;
|
||||
accessTokenEncrypted: string | null;
|
||||
message: MessagingMessage;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
const NumberFromString = type('string|number').pipe((v) => Number(v));
|
||||
|
||||
// Derived from MESSAGING_PROVIDERS so adding a provider doesn't silently fail validation.
|
||||
const MessagingProviderParam = type.enumerated(...MESSAGING_PROVIDERS);
|
||||
|
||||
export const MessagingArkTypeConnectLink = type({
|
||||
provider: MessagingProviderParam,
|
||||
});
|
||||
export type MessagingArgConnectLink = typeof MessagingArkTypeConnectLink.infer;
|
||||
|
||||
export const MessagingArkTypeById = type({
|
||||
id: NumberFromString,
|
||||
});
|
||||
export type MessagingArgById = typeof MessagingArkTypeById.infer;
|
||||
|
||||
export const MessagingArkTypeToggle = type({
|
||||
id: 'number',
|
||||
isActive: 'boolean',
|
||||
});
|
||||
export type MessagingArgToggle = typeof MessagingArkTypeToggle.infer;
|
||||
|
||||
export const MessagingArkTypeUpdateEvents = type({
|
||||
id: 'number',
|
||||
events: 'string[]',
|
||||
});
|
||||
export type MessagingArgUpdateEvents = typeof MessagingArkTypeUpdateEvents.infer;
|
||||
|
||||
// Project routes take goalId from IsProjectGoalOwner (res.locals), not the payload,
|
||||
// so these schemas validate only the non-authorization fields.
|
||||
export const MessagingArkTypeProviderParam = type({
|
||||
provider: MessagingProviderParam,
|
||||
});
|
||||
export type MessagingArgProviderParam = typeof MessagingArkTypeProviderParam.infer;
|
||||
|
||||
export const MessagingArkTypeProjectToggle = type({
|
||||
id: 'number',
|
||||
isActive: 'boolean',
|
||||
});
|
||||
export type MessagingArgProjectToggle = typeof MessagingArkTypeProjectToggle.infer;
|
||||
|
||||
export const MessagingArkTypeProjectDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
export type MessagingArgProjectDelete = typeof MessagingArkTypeProjectDelete.infer;
|
||||
|
||||
export const MessagingArkTypeProjectPostContent = type({
|
||||
id: 'number',
|
||||
postContent: 'boolean',
|
||||
});
|
||||
export type MessagingArgProjectPostContent = typeof MessagingArkTypeProjectPostContent.infer;
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export { escapeHtml } from '../../utils/helpers';
|
||||
|
||||
// Slack mrkdwn requires escaping these three in text (incl. link labels).
|
||||
export function escapeSlackText(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -84,12 +84,20 @@ export class RecurrenceGenerator {
|
||||
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
|
||||
const today = RecurrenceParser.todayInTimezone(rule.timezone);
|
||||
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
// Fixed series follow the calendar schedule; after-completion series
|
||||
// take one interval step from the completion day. Stepping from
|
||||
// max(lastInstanceDate, today) keeps instance dates strictly
|
||||
// increasing, so the (rule_id, instance_date) unique index can
|
||||
// never collide with an earlier instance of the series.
|
||||
const nextDate =
|
||||
rule.scheduleMode === 'after-completion'
|
||||
? RecurrenceParser.nextDateAfterCompletion({ rrule: rule.rrule, afterDate })
|
||||
: RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
if (!nextDate) {
|
||||
await tx
|
||||
.update(RecurrenceRulesSchema)
|
||||
|
||||
@@ -61,10 +61,12 @@ export class RecurrenceManager {
|
||||
return fail('invalid_rule', 'timezone must be a valid IANA name');
|
||||
}
|
||||
|
||||
const scheduleMode = args.scheduleMode ?? 'fixed';
|
||||
let dtstart: Date;
|
||||
let hasTime: boolean;
|
||||
try {
|
||||
RecurrenceParser.validateRuleString(args.rrule);
|
||||
if (scheduleMode === 'after-completion') RecurrenceParser.validateForAfterCompletion(args.rrule);
|
||||
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
@@ -99,6 +101,7 @@ export class RecurrenceManager {
|
||||
dtstart,
|
||||
hasTime,
|
||||
timezone: args.timezone,
|
||||
scheduleMode,
|
||||
lastInstanceDate: originInstanceDate,
|
||||
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
|
||||
creatorId: this.initiatorId,
|
||||
@@ -196,13 +199,26 @@ export class RecurrenceManager {
|
||||
}
|
||||
patch.timezone = args.timezone;
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (args.scheduleMode !== undefined) patch.scheduleMode = args.scheduleMode;
|
||||
const effectiveMode = patch.scheduleMode ?? rule.scheduleMode;
|
||||
if (effectiveMode === 'after-completion') {
|
||||
try {
|
||||
RecurrenceParser.validateForAfterCompletion(patch.rrule ?? rule.rrule);
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
}
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined || patch.scheduleMode !== undefined) {
|
||||
const afterDate = RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone);
|
||||
const nextDate =
|
||||
effectiveMode === 'after-completion'
|
||||
? RecurrenceParser.nextDateAfterCompletion({ rrule: patch.rrule ?? rule.rrule, afterDate })
|
||||
: RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate,
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
|
||||
}
|
||||
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { RRule } from 'rrule';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextDateAfterCompletionArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
|
||||
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
|
||||
const MAX_COUNT = 10000;
|
||||
|
||||
const FREQ_TO_STEP_UNIT: Record<number, 'years' | 'months' | 'weeks' | 'days'> = {
|
||||
[RRule.YEARLY]: 'years',
|
||||
[RRule.MONTHLY]: 'months',
|
||||
[RRule.WEEKLY]: 'weeks',
|
||||
[RRule.DAILY]: 'days',
|
||||
};
|
||||
|
||||
/**
|
||||
* All recurrence math happens in a single floating wall-clock frame:
|
||||
* `dtstart` is a Date whose UTC components equal the wall-clock components of
|
||||
@@ -41,6 +48,40 @@ export class RecurrenceParser {
|
||||
return RRule.parseString(rruleString).count ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After-completion series step from the completion day, so calendar anchors
|
||||
* (BYDAY, BYMONTHDAY) have no defined meaning for them — reject instead of
|
||||
* silently ignoring what the client asked for.
|
||||
*/
|
||||
static validateForAfterCompletion(rruleString: string): void {
|
||||
const options = RRule.parseString(rruleString);
|
||||
if (options.byweekday !== undefined && options.byweekday !== null) {
|
||||
throw new Error('BYDAY is not supported for after-completion series');
|
||||
}
|
||||
if (options.bymonthday !== undefined && options.bymonthday !== null) {
|
||||
throw new Error('BYMONTHDAY is not supported for after-completion series');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next date of an after-completion series: one FREQ/INTERVAL step after
|
||||
* `afterDate` (the completion day), no calendar anchor. Month/year steps
|
||||
* clamp to the last valid day (Jan 31 + 1 month → Feb 28). COUNT is
|
||||
* enforced by the caller via instances_created (same as fixed series);
|
||||
* returns null when the step lands past UNTIL — the series is over.
|
||||
*/
|
||||
static nextDateAfterCompletion(args: NextDateAfterCompletionArgs): string | null {
|
||||
const options = RRule.parseString(args.rrule);
|
||||
const unit = options.freq !== undefined ? FREQ_TO_STEP_UNIT[options.freq] : undefined;
|
||||
if (!unit) return null;
|
||||
const nextDate = DateTime.fromISO(args.afterDate, { zone: 'utc' })
|
||||
.plus({ [unit]: options.interval ?? 1 })
|
||||
.toISODate();
|
||||
if (!nextDate) return null;
|
||||
if (options.until && nextDate > RecurrenceParser.toIsoDate(options.until)) return null;
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* First occurrence date strictly after `afterDate`, skipping explicit skip
|
||||
* dates. COUNT is intentionally stripped: the cap is "N materialized
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type } from 'arktype';
|
||||
import type {
|
||||
RecurrenceRulesSchemaTypeForInsert,
|
||||
RecurrenceRulesSchemaTypeForSelect,
|
||||
RecurrenceScheduleMode,
|
||||
TasksSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
|
||||
@@ -12,6 +13,7 @@ export const RecurrenceArkTypeCreate = type({
|
||||
rrule: 'string > 0',
|
||||
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
|
||||
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
|
||||
'scheduleMode?': '"fixed" | "after-completion"',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
});
|
||||
|
||||
@@ -20,6 +22,7 @@ export const RecurrenceArkTypeUpdate = type({
|
||||
'rrule?': 'string > 0',
|
||||
'dtstart?': 'string',
|
||||
'timezone?': 'string > 0',
|
||||
'scheduleMode?': '"fixed" | "after-completion"',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
'templateOverrides?': type({
|
||||
'description?': 'string',
|
||||
@@ -63,6 +66,11 @@ export type NextOccurrenceArgs = {
|
||||
afterDate: string;
|
||||
skipDates: Set<string>;
|
||||
};
|
||||
export type NextDateAfterCompletionArgs = {
|
||||
rrule: string;
|
||||
/** 'YYYY-MM-DD' — the completion day; the next date is one FREQ/INTERVAL step after it. */
|
||||
afterDate: string;
|
||||
};
|
||||
export type InstanceWindowArgs = {
|
||||
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
|
||||
occurrenceDate: string;
|
||||
@@ -93,6 +101,7 @@ export type RecurrenceRulePatchArgs = {
|
||||
dtstart: Date;
|
||||
hasTime: boolean;
|
||||
timezone: string;
|
||||
scheduleMode: RecurrenceScheduleMode;
|
||||
state: 'active' | 'paused' | 'ended';
|
||||
lastInstanceDate: string;
|
||||
instancesCreated: number;
|
||||
|
||||
@@ -3,20 +3,129 @@ import { type } from 'arktype'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
import type { Request, Response } from 'express'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { PublicApiUrl } from '../../modules/public-url'
|
||||
import { logError } from '../../utils/api'
|
||||
import { generateString, isEmail } from '../../utils/helpers'
|
||||
import { generateLetters, generateString } 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'
|
||||
import { parseSamlMetadata } from './saml-metadata-parser'
|
||||
import { generateLoginCode, stripSecrets, validateMetadataUrl } from './sso.utils'
|
||||
import { SsoConfigArkTypeCreate, SsoConfigArkTypeUpdate } from './types'
|
||||
import { generateLoginCode, isSsoDomainVerified, stripSecrets, validateMetadataUrl } from './sso.utils'
|
||||
import {
|
||||
SsoConfigArkTypeCreate,
|
||||
SsoConfigArkTypeUpdate,
|
||||
SsoDomainNotVerifiedError,
|
||||
type ApplySsoIdpEmailArgs,
|
||||
type ResolveSsoUserArgs,
|
||||
type ResolveSsoUserResult,
|
||||
type SsoCallbackError,
|
||||
} from './types'
|
||||
import type { UserDbRecord } from '../../types/auth.types'
|
||||
|
||||
export class SsoController {
|
||||
private readonly ssoRepo = new SsoRepository()
|
||||
private readonly authModel = new AuthModel()
|
||||
private readonly orgRepo = new OrganizationRepository()
|
||||
private readonly goalsRepo = new GoalsRepository()
|
||||
|
||||
private async resolveLogin(preferredUsername?: string): Promise<string> {
|
||||
const base = preferredUsername?.trim().slice(0, 50)
|
||||
if (!base) return generateString(7)
|
||||
|
||||
if (!(await this.authModel.getUserByLogin(base))) return base
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const suffix = `.${generateLetters(3)}`
|
||||
const candidate = `${base.slice(0, 50 - suffix.length)}${suffix}`
|
||||
if (!(await this.authModel.getUserByLogin(candidate))) return candidate
|
||||
}
|
||||
|
||||
return generateString(7)
|
||||
}
|
||||
|
||||
private redirectSsoError(res: Response, error: SsoCallbackError) {
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=${error}`)
|
||||
}
|
||||
|
||||
private async createSsoUser(args: ResolveSsoUserArgs): Promise<UserDbRecord | false> {
|
||||
const password = generateString(16)
|
||||
const login = await this.resolveLogin(args.preferredUsername)
|
||||
const id = await this.authModel.registerUserInDb({
|
||||
login,
|
||||
email: args.email,
|
||||
password: hashSync(password, 10),
|
||||
block: 0,
|
||||
confirmEmailCode: '',
|
||||
})
|
||||
|
||||
if (!id) {
|
||||
$logger.error('Failed to create user during SSO login')
|
||||
return false
|
||||
}
|
||||
|
||||
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
|
||||
if (personalOrg) {
|
||||
await this.orgRepo.addMember(personalOrg.id, args.email, 'owner')
|
||||
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
|
||||
}
|
||||
|
||||
return await this.authModel.fetchUserById(id)
|
||||
}
|
||||
|
||||
private async applyIdpEmail(args: ApplySsoIdpEmailArgs): Promise<'ok' | 'email_in_use' | 'error'> {
|
||||
if (args.user.email.toLowerCase() === args.email) return 'ok'
|
||||
|
||||
const taken = await this.authModel.getUserByLogin(args.email, true)
|
||||
if (taken && taken.id !== args.user.id) return 'email_in_use'
|
||||
|
||||
const result = await this.authModel.updateUserEmail({
|
||||
userId: args.user.id,
|
||||
oldEmail: args.user.email,
|
||||
email: args.email,
|
||||
})
|
||||
if (result === 'conflict') return 'email_in_use'
|
||||
if (result !== 'ok') return 'error'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
private async resolveSsoUser(args: ResolveSsoUserArgs): Promise<ResolveSsoUserResult> {
|
||||
const identity = await this.ssoRepo.findIdentity({
|
||||
ssoConfigId: args.ssoConfigId,
|
||||
externalId: args.externalId,
|
||||
})
|
||||
|
||||
if (identity) {
|
||||
const user = await this.authModel.fetchUserById(identity.userId)
|
||||
if (!user) return { ok: false, error: 'authentication_failed' }
|
||||
|
||||
const emailResult = await this.applyIdpEmail({ user, email: args.email })
|
||||
if (emailResult === 'email_in_use') return { ok: false, error: 'email_in_use' }
|
||||
if (emailResult !== 'ok') return { ok: false, error: 'authentication_failed' }
|
||||
|
||||
const refreshed = await this.authModel.fetchUserById(user.id)
|
||||
if (!refreshed) return { ok: false, error: 'authentication_failed' }
|
||||
return { ok: true, user: refreshed }
|
||||
}
|
||||
|
||||
const existing = await this.authModel.getUserByLogin(args.email, true)
|
||||
if (existing) {
|
||||
const linked = await this.ssoRepo.findIdentityByUser({
|
||||
ssoConfigId: args.ssoConfigId,
|
||||
userId: existing.id,
|
||||
})
|
||||
if (linked && linked.externalId !== args.externalId) {
|
||||
return { ok: false, error: 'email_in_use' }
|
||||
}
|
||||
return { ok: true, user: existing }
|
||||
}
|
||||
|
||||
const created = await this.createSsoUser(args)
|
||||
if (!created) return { ok: false, error: 'authentication_failed' }
|
||||
return { ok: true, user: created }
|
||||
}
|
||||
|
||||
initiateLogin = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
@@ -25,6 +134,10 @@ export class SsoController {
|
||||
const config = await this.ssoRepo.findEnabledById(configId)
|
||||
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
|
||||
|
||||
if (!isSsoDomainVerified(config)) {
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = createSsoProvider(config)
|
||||
const relayState = JSON.stringify({ platform: req.query.platform || '' })
|
||||
@@ -42,49 +155,36 @@ export class SsoController {
|
||||
const config = await this.ssoRepo.findEnabledById(configId)
|
||||
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
|
||||
|
||||
if (!isSsoDomainVerified(config)) {
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = createSsoProvider(config)
|
||||
const ssoResult = await provider.handleCallback(req)
|
||||
|
||||
if (config.emailDomainRestriction) {
|
||||
const domain = ssoResult.email.split('@')[1]
|
||||
if (domain !== config.emailDomainRestriction) {
|
||||
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
|
||||
}
|
||||
if (!config.emailDomainRestriction) {
|
||||
return this.redirectSsoError(res, 'authentication_failed')
|
||||
}
|
||||
|
||||
let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
|
||||
|
||||
if (!userData) {
|
||||
const password = generateString(16)
|
||||
const login = generateString(7)
|
||||
const id = await this.authModel.registerUserInDb({
|
||||
login,
|
||||
email: ssoResult.email,
|
||||
password: hashSync(password, 10),
|
||||
block: 0,
|
||||
confirmEmailCode: '',
|
||||
})
|
||||
|
||||
if (!id) {
|
||||
$logger.error('Failed to create user during SSO login')
|
||||
return res.status(500).tvJson({ message: 'Failed to create user' })
|
||||
}
|
||||
|
||||
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
|
||||
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')
|
||||
}
|
||||
|
||||
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
|
||||
const domain = ssoResult.email.split('@')[1]
|
||||
if (domain !== config.emailDomainRestriction) {
|
||||
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
return res.status(500).tvJson({ message: 'Failed to resolve user after SSO login' })
|
||||
const resolved = await this.resolveSsoUser({
|
||||
ssoConfigId: config.id,
|
||||
email: ssoResult.email,
|
||||
externalId: ssoResult.externalId,
|
||||
preferredUsername: ssoResult.preferredUsername,
|
||||
})
|
||||
if (!resolved.ok) {
|
||||
return this.redirectSsoError(res, resolved.error)
|
||||
}
|
||||
|
||||
await this.orgRepo.addMember(config.organizationId, ssoResult.email, config.defaultOrgRole)
|
||||
const userData = resolved.user
|
||||
|
||||
await this.orgRepo.addMember(config.organizationId, userData.email, config.defaultOrgRole)
|
||||
|
||||
await this.ssoRepo.upsertIdentity({
|
||||
userId: userData.id,
|
||||
@@ -128,7 +228,7 @@ export class SsoController {
|
||||
if (!domain) return res.tvJson(null)
|
||||
|
||||
const config = await this.ssoRepo.findEnabledByDomain(domain)
|
||||
if (!config) return res.tvJson(null)
|
||||
if (!config || !isSsoDomainVerified(config)) return res.tvJson(null)
|
||||
|
||||
return res.tvJson({
|
||||
id: config.id,
|
||||
@@ -137,6 +237,16 @@ export class SsoController {
|
||||
})
|
||||
}
|
||||
|
||||
getPublicUrls = async (req: Request, res: Response) => {
|
||||
const base = PublicApiUrl.base(req)
|
||||
return res.tvJson({
|
||||
apiBaseUrl: base,
|
||||
callbackUrlTemplate: `${base}/module/sso/callback/{id}`,
|
||||
scimEndpointUrl: `${base}/scim/v2`,
|
||||
apiPublicUrlConfigured: PublicApiUrl.configured() !== null,
|
||||
})
|
||||
}
|
||||
|
||||
listConfigs = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.query.organizationId)
|
||||
if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' })
|
||||
@@ -151,11 +261,18 @@ export class SsoController {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const existing = await this.ssoRepo.findEnabledByDomain(out.emailDomainRestriction)
|
||||
if (existing) {
|
||||
const domain = out.emailDomainRestriction.toLowerCase()
|
||||
|
||||
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: out.organizationId })
|
||||
if (sameOrg) {
|
||||
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
|
||||
}
|
||||
|
||||
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
|
||||
if (verified) {
|
||||
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
|
||||
}
|
||||
|
||||
const config = await req.appUser.ssoManager.createConfig(out).catch(logError)
|
||||
if (!config) {
|
||||
return res.status(500).tvJson({ message: 'Failed to create SSO config' })
|
||||
@@ -172,8 +289,33 @@ export class SsoController {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError)
|
||||
return res.tvJson(config ? stripSecrets(config) : null)
|
||||
if (out.emailDomainRestriction) {
|
||||
const domain = out.emailDomainRestriction.toLowerCase()
|
||||
|
||||
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
|
||||
if (verified && verified.id !== configId) {
|
||||
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
|
||||
}
|
||||
|
||||
const current = await this.ssoRepo.findById(configId)
|
||||
if (current) {
|
||||
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: current.organizationId })
|
||||
if (sameOrg && sameOrg.id !== configId) {
|
||||
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await req.appUser.ssoManager.updateConfig(configId, out)
|
||||
return res.tvJson(config ? stripSecrets(config) : null)
|
||||
} catch (error) {
|
||||
if (error instanceof SsoDomainNotVerifiedError) {
|
||||
return res.status(403).tvJson({ message: 'Domain is not verified' })
|
||||
}
|
||||
logError(error)
|
||||
return res.tvJson(null)
|
||||
}
|
||||
}
|
||||
|
||||
parseMetadata = async (req: Request, res: Response) => {
|
||||
@@ -199,6 +341,28 @@ export class SsoController {
|
||||
}
|
||||
}
|
||||
|
||||
startDomainVerification = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const result = await req.appUser.ssoManager.startDomainVerification(configId).catch(logError)
|
||||
if (!result) {
|
||||
return res.status(404).tvJson({ message: 'SSO config not found' })
|
||||
}
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
checkDomainVerification = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
const result = await req.appUser.ssoManager.checkDomainVerification(configId).catch(logError)
|
||||
if (!result) {
|
||||
return res.status(404).tvJson({ message: 'SSO config not found' })
|
||||
}
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
generateScimToken = async (req: Request, res: Response) => {
|
||||
const configId = Number(req.params.configId)
|
||||
if (!configId) return res.status(400).end()
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
import { encrypt, encryptField } from '../../utils/crypto'
|
||||
import { SsoRepository } from './SsoRepository'
|
||||
import { SSO_SECRET_FIELDS } from './sso.utils'
|
||||
import type { SsoConfigArgCreate, SsoConfigArgUpdate } from './types'
|
||||
import {
|
||||
SSO_SECRET_FIELDS,
|
||||
generateDomainVerifyToken,
|
||||
isSsoDomainVerified,
|
||||
isTrustedSsoDomain,
|
||||
proveSsoDomainOwnership,
|
||||
ssoDomainVerifyDnsRecord,
|
||||
ssoDomainVerifyHttpUrl,
|
||||
} from './sso.utils'
|
||||
import {
|
||||
SsoDomainNotVerifiedError,
|
||||
type CheckDomainVerificationResult,
|
||||
type SsoConfigArgCreate,
|
||||
type SsoConfigArgUpdate,
|
||||
type StartDomainVerificationResult,
|
||||
} from './types'
|
||||
|
||||
export class SsoManager {
|
||||
public readonly repository: SsoRepository
|
||||
@@ -18,11 +32,14 @@ export class SsoManager {
|
||||
}
|
||||
|
||||
async createConfig(data: SsoConfigArgCreate) {
|
||||
const domain = data.emailDomainRestriction.toLowerCase()
|
||||
const trusted = isTrustedSsoDomain(domain)
|
||||
|
||||
return await this.repository.create({
|
||||
organizationId: data.organizationId,
|
||||
protocol: data.protocol,
|
||||
displayName: data.displayName,
|
||||
enabled: data.enabled ?? 1,
|
||||
enabled: trusted ? (data.enabled ?? 1) : 0,
|
||||
samlEntryPoint: data.samlEntryPoint ?? null,
|
||||
samlIssuer: data.samlIssuer ?? null,
|
||||
samlCert: encryptField(data.samlCert),
|
||||
@@ -36,12 +53,22 @@ export class SsoManager {
|
||||
oidcCallbackUrl: data.oidcCallbackUrl ?? null,
|
||||
oidcScope: data.oidcScope ?? null,
|
||||
defaultOrgRole: data.defaultOrgRole ?? 'member',
|
||||
emailDomainRestriction: data.emailDomainRestriction.toLowerCase(),
|
||||
emailDomainRestriction: domain,
|
||||
domainVerifyToken: generateDomainVerifyToken(),
|
||||
domainVerifiedAt: trusted ? new Date() : null,
|
||||
})
|
||||
}
|
||||
|
||||
async updateConfig(configId: number, data: SsoConfigArgUpdate) {
|
||||
const encrypted: Partial<SsoConfigArgUpdate> = { ...data }
|
||||
const current = await this.repository.findById(configId)
|
||||
if (!current) return null
|
||||
|
||||
const encrypted: Partial<SsoConfigArgUpdate> & {
|
||||
domainVerifyToken?: string
|
||||
domainVerifiedAt?: Date | null
|
||||
enabled?: number
|
||||
} = { ...data }
|
||||
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
if (field in encrypted) {
|
||||
if (encrypted[field]) {
|
||||
@@ -51,9 +78,87 @@ export class SsoManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.emailDomainRestriction) {
|
||||
const domain = data.emailDomainRestriction.toLowerCase()
|
||||
encrypted.emailDomainRestriction = domain
|
||||
if (domain !== current.emailDomainRestriction) {
|
||||
const trusted = isTrustedSsoDomain(domain)
|
||||
encrypted.domainVerifyToken = generateDomainVerifyToken()
|
||||
encrypted.domainVerifiedAt = trusted ? new Date() : null
|
||||
if (!trusted) encrypted.enabled = 0
|
||||
await this.repository.deleteIdentitiesByConfig(configId)
|
||||
}
|
||||
}
|
||||
|
||||
const nextDomain = encrypted.emailDomainRestriction ?? current.emailDomainRestriction
|
||||
const nextVerifiedAt = 'domainVerifiedAt' in encrypted
|
||||
? encrypted.domainVerifiedAt
|
||||
: current.domainVerifiedAt
|
||||
const wouldBeVerified = isTrustedSsoDomain(nextDomain) || !!nextVerifiedAt
|
||||
|
||||
if (data.enabled === 1 && !wouldBeVerified) {
|
||||
throw new SsoDomainNotVerifiedError()
|
||||
}
|
||||
|
||||
return await this.repository.update(configId, encrypted)
|
||||
}
|
||||
|
||||
async startDomainVerification(configId: number): Promise<StartDomainVerificationResult | null> {
|
||||
const config = await this.repository.findById(configId)
|
||||
if (!config) return null
|
||||
|
||||
let token = config.domainVerifyToken
|
||||
if (!token) {
|
||||
token = generateDomainVerifyToken()
|
||||
const updated = await this.repository.update(configId, { domainVerifyToken: token })
|
||||
if (!updated) return null
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
dnsRecord: ssoDomainVerifyDnsRecord(token),
|
||||
httpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
|
||||
isDomainVerified: isSsoDomainVerified({ ...config, domainVerifyToken: token }),
|
||||
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
|
||||
}
|
||||
}
|
||||
|
||||
async checkDomainVerification(configId: number): Promise<CheckDomainVerificationResult | null> {
|
||||
const config = await this.repository.findById(configId)
|
||||
if (!config) return null
|
||||
|
||||
if (!config.domainVerifyToken) {
|
||||
return {
|
||||
verified: isSsoDomainVerified(config),
|
||||
method: isTrustedSsoDomain(config.emailDomainRestriction) ? 'trusted' : null
|
||||
}
|
||||
}
|
||||
|
||||
const method = await proveSsoDomainOwnership({
|
||||
domain: config.emailDomainRestriction,
|
||||
token: config.domainVerifyToken,
|
||||
})
|
||||
|
||||
if (!method) {
|
||||
return { verified: isSsoDomainVerified(config), method: null }
|
||||
}
|
||||
|
||||
if (!config.domainVerifiedAt || method === 'trusted') {
|
||||
const updated = await this.repository.update(configId, {
|
||||
domainVerifiedAt: new Date(),
|
||||
enabled: 1,
|
||||
})
|
||||
// The partial unique index rejects a second verified config for the same
|
||||
// domain another organization proved ownership first.
|
||||
if (!updated) {
|
||||
return { verified: false, method: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { verified: true, method }
|
||||
}
|
||||
|
||||
async deleteConfig(configId: number) {
|
||||
return await this.repository.delete(configId)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import {
|
||||
SsoConfigsSchema,
|
||||
SsoIdentitiesSchema,
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import type {
|
||||
FindSsoConfigByDomainAndOrgArgs,
|
||||
FindSsoIdentityArgs,
|
||||
FindSsoIdentityByUserArgs,
|
||||
UpsertSsoIdentityArgs,
|
||||
} from './types'
|
||||
|
||||
export class SsoRepository {
|
||||
private readonly db: Database
|
||||
@@ -16,6 +22,38 @@ export class SsoRepository {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async findVerifiedByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.emailDomainRestriction, domain.toLowerCase()),
|
||||
isNotNull(SsoConfigsSchema.domainVerifiedAt),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findByDomainAndOrg(args: FindSsoConfigByDomainAndOrgArgs): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoConfigsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoConfigsSchema.emailDomainRestriction, args.domain.toLowerCase()),
|
||||
eq(SsoConfigsSchema.organizationId, args.organizationId),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!result || result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findEnabledByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
@@ -146,12 +184,41 @@ export class SsoRepository {
|
||||
return !!(result?.rowCount && result.rowCount > 0)
|
||||
}
|
||||
|
||||
async upsertIdentity(data: {
|
||||
userId: number
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
email: string
|
||||
}): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
async findIdentity(args: FindSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const result = await this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoIdentitiesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
|
||||
eq(SsoIdentitiesSchema.externalId, args.externalId),
|
||||
)
|
||||
)
|
||||
if (result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async findIdentityByUser(args: FindSsoIdentityByUserArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const result = await this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SsoIdentitiesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
|
||||
eq(SsoIdentitiesSchema.userId, args.userId),
|
||||
)
|
||||
)
|
||||
if (result.length === 0) return null
|
||||
return result[0]
|
||||
}
|
||||
|
||||
async deleteIdentitiesByConfig(ssoConfigId: number): Promise<void> {
|
||||
await this.db.dbDrizzle
|
||||
.delete(SsoIdentitiesSchema)
|
||||
.where(eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId))
|
||||
}
|
||||
|
||||
async upsertIdentity(data: UpsertSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
|
||||
const existing = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
|
||||
import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin'
|
||||
import { RequireLoginMethod } from '../auth/middlewares/require-login-method'
|
||||
import { SsoController } from './SsoController'
|
||||
|
||||
export default class SsoRoutes implements Routable {
|
||||
@@ -20,16 +21,19 @@ export default class SsoRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/providers', this.controller.listPublicProviders)
|
||||
this.router.get('/login/:configId', this.controller.initiateLogin)
|
||||
this.router.get('/callback/:configId', this.controller.handleCallback)
|
||||
this.router.post('/callback/:configId', this.controller.handleCallback)
|
||||
this.router.get('/providers', [RequireLoginMethod('sso')], this.controller.listPublicProviders)
|
||||
this.router.get('/login/:configId', [RequireLoginMethod('sso')], this.controller.initiateLogin)
|
||||
this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
|
||||
this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
|
||||
|
||||
this.router.get('/admin/public-urls', [IsLoggedIn], this.controller.getPublicUrls)
|
||||
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata)
|
||||
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs)
|
||||
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig)
|
||||
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.updateConfig)
|
||||
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.deleteConfig)
|
||||
this.router.post('/admin/configs/:configId/verify-domain', [IsLoggedIn, IsSsoConfigAdmin], this.controller.startDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin], this.controller.checkDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin], this.controller.generateScimToken)
|
||||
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin], this.controller.toggleScim)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto'
|
||||
import * as client from 'openid-client'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { PublicApiUrl } from '../../../modules/public-url'
|
||||
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
|
||||
|
||||
export class OidcProvider implements SsoProvider {
|
||||
@@ -28,7 +29,12 @@ export class OidcProvider implements SsoProvider {
|
||||
return this.oidcConfig
|
||||
}
|
||||
|
||||
async initiateLogin(_req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
private resolveCallbackUrl(req: Request): string {
|
||||
return this.config.oidcCallbackUrl?.trim()
|
||||
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
|
||||
}
|
||||
|
||||
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
const config = await this.getOidcConfig()
|
||||
const scope = this.config.oidcScope ?? 'openid email profile'
|
||||
const codeVerifier = client.randomPKCECodeVerifier()
|
||||
@@ -63,7 +69,7 @@ export class OidcProvider implements SsoProvider {
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: this.config.oidcCallbackUrl!,
|
||||
redirect_uri: this.resolveCallbackUrl(req),
|
||||
scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
@@ -110,10 +116,12 @@ export class OidcProvider implements SsoProvider {
|
||||
throw new Error('CSRF state mismatch — possible CSRF attack')
|
||||
}
|
||||
|
||||
const currentUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`)
|
||||
const callbackOrigin = new URL(this.resolveCallbackUrl(req)).origin
|
||||
const currentUrl = new URL(req.originalUrl, callbackOrigin)
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: returnedState,
|
||||
expectedNonce: storedNonce,
|
||||
})
|
||||
|
||||
const claims = tokens.claims()
|
||||
@@ -122,14 +130,11 @@ 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,
|
||||
displayName: claims.name as string | undefined,
|
||||
preferredUsername: claims.preferred_username as string | undefined,
|
||||
provider: `oidc-${this.config.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { PublicApiUrl } from '../../../modules/public-url'
|
||||
import type { SamlOptionsArgs } from '../types'
|
||||
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
|
||||
import { SamlDbCacheProvider } from './saml-cache-provider'
|
||||
|
||||
@@ -11,12 +13,12 @@ function normalizeCert(cert: string): string {
|
||||
.replace(/[\s\r\n]/g, '')
|
||||
}
|
||||
|
||||
function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertion' | 'response') {
|
||||
function buildSamlOptions({ config, mode, callbackUrl }: SamlOptionsArgs) {
|
||||
return {
|
||||
entryPoint: config.samlEntryPoint!,
|
||||
issuer: config.samlIssuer!,
|
||||
idpCert: normalizeCert(config.samlCert!),
|
||||
callbackUrl: config.samlCallbackUrl!,
|
||||
callbackUrl,
|
||||
wantAssertionsSigned: mode === 'assertion',
|
||||
wantAuthnResponseSigned: mode === 'response',
|
||||
validateInResponseTo: ValidateInResponseTo.always,
|
||||
@@ -31,29 +33,38 @@ function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertio
|
||||
}
|
||||
|
||||
export class SamlProvider implements SsoProvider {
|
||||
private readonly samlAssertion: SAML
|
||||
private readonly samlResponse: SAML
|
||||
private readonly config: SsoConfigsSchemaTypeForSelect
|
||||
|
||||
constructor(config: SsoConfigsSchemaTypeForSelect) {
|
||||
this.config = config
|
||||
this.samlAssertion = new SAML(buildSamlOptions(config, 'assertion'))
|
||||
this.samlResponse = new SAML(buildSamlOptions(config, 'response'))
|
||||
}
|
||||
|
||||
private resolveCallbackUrl(req: Request): string {
|
||||
return this.config.samlCallbackUrl?.trim()
|
||||
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
|
||||
}
|
||||
|
||||
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
|
||||
const loginUrl = await this.samlAssertion.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
|
||||
const saml = new SAML(buildSamlOptions({
|
||||
config: this.config,
|
||||
mode: 'assertion',
|
||||
callbackUrl: this.resolveCallbackUrl(req),
|
||||
}))
|
||||
const loginUrl = await saml.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
|
||||
res.redirect(loginUrl)
|
||||
}
|
||||
|
||||
async handleCallback(req: Request): Promise<SsoAuthResult> {
|
||||
const callbackUrl = this.resolveCallbackUrl(req)
|
||||
let profile
|
||||
|
||||
try {
|
||||
const result = await this.samlAssertion.validatePostResponseAsync(req.body)
|
||||
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'assertion', callbackUrl }))
|
||||
const result = await saml.validatePostResponseAsync(req.body)
|
||||
profile = result.profile
|
||||
} catch {
|
||||
const result = await this.samlResponse.validatePostResponseAsync(req.body)
|
||||
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'response', callbackUrl }))
|
||||
const result = await saml.validatePostResponseAsync(req.body)
|
||||
profile = result.profile
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SsoAuthResult = {
|
||||
email: string
|
||||
externalId: string
|
||||
displayName?: string
|
||||
preferredUsername?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { resolveTxt } from 'node:dns/promises'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { decryptField } from '../../utils/crypto'
|
||||
import { generateString } from '../../utils/helpers'
|
||||
import type { CheckSsoDomainProofArgs, SsoDomainVerificationMethod } from './types'
|
||||
|
||||
export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const
|
||||
|
||||
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
export const SSO_DOMAIN_TXT_PREFIX = 'taskview-sso-verify='
|
||||
export const SSO_DOMAIN_WELL_KNOWN_PATH = '/.well-known/taskview-sso-verify.txt'
|
||||
|
||||
export function generateDomainVerifyToken(): string {
|
||||
return `tvdom_${randomBytes(32).toString('hex')}`
|
||||
}
|
||||
|
||||
export function trustedSsoDomains(): string[] {
|
||||
const raw = process.env.SSO_TRUSTED_DOMAINS
|
||||
if (!raw?.trim()) return []
|
||||
return raw
|
||||
.split(',')
|
||||
.map((domain) => domain.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function isTrustedSsoDomain(domain: string): boolean {
|
||||
return trustedSsoDomains().includes(domain.trim().toLowerCase())
|
||||
}
|
||||
|
||||
export function isSsoDomainVerified(config: SsoConfigsSchemaTypeForSelect): boolean {
|
||||
if (isTrustedSsoDomain(config.emailDomainRestriction)) return true
|
||||
return !!config.domainVerifiedAt
|
||||
}
|
||||
|
||||
export function ssoDomainVerifyHttpUrl(domain: string): string {
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
|
||||
return `${protocol}://${domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`
|
||||
}
|
||||
|
||||
export function ssoDomainVerifyDnsRecord(token: string): string {
|
||||
return `${SSO_DOMAIN_TXT_PREFIX}${token}`
|
||||
}
|
||||
|
||||
export function toClientSsoConfig(config: SsoConfigsSchemaTypeForSelect) {
|
||||
const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config
|
||||
const token = config.domainVerifyToken
|
||||
return {
|
||||
...safe,
|
||||
hasSamlCert: !!samlCert,
|
||||
@@ -13,9 +51,65 @@ export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
hasSamlSigningCert: !!samlSigningCert,
|
||||
hasOidcClientSecret: !!oidcClientSecret,
|
||||
hasScimToken: !!scimToken,
|
||||
isDomainVerified: isSsoDomainVerified(config),
|
||||
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
|
||||
domainVerifyDnsRecord: token ? ssoDomainVerifyDnsRecord(token) : null,
|
||||
domainVerifyHttpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
|
||||
}
|
||||
}
|
||||
|
||||
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
|
||||
return toClientSsoConfig(config)
|
||||
}
|
||||
|
||||
function tokenMatchesProof(body: string, token: string): boolean {
|
||||
const trimmed = body.trim()
|
||||
return trimmed === token || trimmed === ssoDomainVerifyDnsRecord(token)
|
||||
}
|
||||
|
||||
export async function checkSsoDomainDnsTxt(args: CheckSsoDomainProofArgs): Promise<boolean> {
|
||||
try {
|
||||
const records = await resolveTxt(args.domain)
|
||||
return records.some((chunks) => tokenMatchesProof(chunks.join(''), args.token))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Promise<boolean> {
|
||||
const urls = process.env.NODE_ENV === 'production'
|
||||
? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`]
|
||||
: [
|
||||
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
|
||||
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
|
||||
]
|
||||
|
||||
for (const url of urls) {
|
||||
const urlError = validateMetadataUrl(url)
|
||||
if (urlError) continue
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!response.ok) continue
|
||||
if (tokenMatchesProof(await response.text(), args.token)) return true
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function proveSsoDomainOwnership(args: CheckSsoDomainProofArgs): Promise<SsoDomainVerificationMethod | null> {
|
||||
if (isTrustedSsoDomain(args.domain)) return 'trusted'
|
||||
if (await checkSsoDomainDnsTxt(args)) return 'dns'
|
||||
if (await checkSsoDomainHttpFile(args)) return 'http'
|
||||
return null
|
||||
}
|
||||
|
||||
export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect {
|
||||
return {
|
||||
...config,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type } from 'arktype'
|
||||
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import type { UserDbRecord } from '../../types/auth.types'
|
||||
|
||||
export type SamlOptionsArgs = {
|
||||
config: SsoConfigsSchemaTypeForSelect
|
||||
mode: 'assertion' | 'response'
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
export const SsoProtocols = {
|
||||
SAML: 'saml',
|
||||
@@ -52,7 +60,76 @@ export const SsoConfigArkTypeUpdate = type({
|
||||
'oidcScope?': 'string',
|
||||
|
||||
'defaultOrgRole?': "'admin' | 'member'",
|
||||
'emailDomainRestriction?': 'string',
|
||||
'emailDomainRestriction?': 'string > 0',
|
||||
})
|
||||
|
||||
export type SsoConfigArgUpdate = typeof SsoConfigArkTypeUpdate.infer
|
||||
|
||||
export type CheckSsoDomainProofArgs = {
|
||||
domain: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type SsoDomainVerificationMethod = 'dns' | 'http' | 'trusted'
|
||||
|
||||
export type StartDomainVerificationResult = {
|
||||
token: string
|
||||
dnsRecord: string
|
||||
httpUrl: string
|
||||
isDomainVerified: boolean
|
||||
isDomainTrusted: boolean
|
||||
}
|
||||
|
||||
export type CheckDomainVerificationResult = {
|
||||
verified: boolean
|
||||
method: SsoDomainVerificationMethod | null
|
||||
}
|
||||
|
||||
export class SsoDomainNotVerifiedError extends Error {
|
||||
readonly code = 'domain_unverified'
|
||||
|
||||
constructor() {
|
||||
super('SSO domain is not verified')
|
||||
this.name = 'SsoDomainNotVerifiedError'
|
||||
}
|
||||
}
|
||||
|
||||
export type FindSsoConfigByDomainAndOrgArgs = {
|
||||
domain: string
|
||||
organizationId: number
|
||||
}
|
||||
|
||||
export type FindSsoIdentityArgs = {
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
}
|
||||
|
||||
export type FindSsoIdentityByUserArgs = {
|
||||
ssoConfigId: number
|
||||
userId: number
|
||||
}
|
||||
|
||||
export type UpsertSsoIdentityArgs = {
|
||||
userId: number
|
||||
ssoConfigId: number
|
||||
externalId: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type ResolveSsoUserArgs = {
|
||||
ssoConfigId: number
|
||||
email: string
|
||||
externalId: string
|
||||
preferredUsername?: string
|
||||
}
|
||||
|
||||
export type ApplySsoIdpEmailArgs = {
|
||||
user: UserDbRecord
|
||||
email: string
|
||||
}
|
||||
|
||||
export type SsoCallbackError = 'authentication_failed' | 'email_in_use'
|
||||
|
||||
export type ResolveSsoUserResult =
|
||||
| { ok: true, user: UserDbRecord }
|
||||
| { ok: false, error: SsoCallbackError }
|
||||
|
||||
@@ -89,7 +89,7 @@ export class StartManager {
|
||||
await this.fetchSharedGoals(organizationId);
|
||||
const goalIds = await this.getAllGoalsIds(organizationId);
|
||||
|
||||
const tasks = await this.repository.searchTask(description.trim(), goalIds);
|
||||
const tasks = await this.repository.searchTask({ description, goalsIds: goalIds });
|
||||
return tasks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
import { GoalsSchema, GoalsListSchema } from 'taskview-db-schemas';
|
||||
import { and, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm';
|
||||
import { GoalsSchema, GoalsListSchema, TasksSchema } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
@@ -8,7 +8,7 @@ import { logError } from '../../utils/api';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { TagToTaskInDb } from '../tags/tags.types';
|
||||
import { TaskItemForClient } from '../tasks/TaskItemForClient';
|
||||
import type { AssigneesForTaskFromDb, FetchAllListsResult, UsersByProjectsFromDb } from './start.types';
|
||||
import type { AssigneesForTaskFromDb, FetchAllListsResult, SearchTaskArgs, SearchTaskResult, UsersByProjectsFromDb } from './start.types';
|
||||
|
||||
//TODO: refactor
|
||||
export class StartRepository {
|
||||
@@ -371,31 +371,38 @@ export class StartRepository {
|
||||
return [...taskIdToTaskMap.values()];
|
||||
}
|
||||
|
||||
async searchTask(description: string, goalsIds: number[]): Promise<TaskItemForClient[]> {
|
||||
if (goalsIds.length === 0 || !description.trim()) {
|
||||
async searchTask(args: SearchTaskArgs): Promise<SearchTaskResult[]> {
|
||||
const description = args.description.trim();
|
||||
if (args.goalsIds.length === 0 || !description) {
|
||||
return [];
|
||||
}
|
||||
const placeholders = goalsIds.map((_id, index) => {
|
||||
return `$${index + 1}`;
|
||||
});
|
||||
|
||||
const result = await this.db.query<TaskItemInDb>(
|
||||
`select * from tasks.tasks where goal_id in (${placeholders.join(',')}) and complete = FALSE and parent_id is null and description ILIKE $${goalsIds.length + 1}`,
|
||||
[...goalsIds, `%${description}%`]
|
||||
const idMatch = description.match(/^#(\d+)$/);
|
||||
const searchCondition = idMatch
|
||||
? eq(TasksSchema.id, Number(idMatch[1]))
|
||||
: and(
|
||||
eq(TasksSchema.complete, false),
|
||||
isNull(TasksSchema.parentId),
|
||||
ilike(TasksSchema.description, `%${description}%`),
|
||||
);
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(inArray(TasksSchema.goalId, args.goalsIds), searchCondition))
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map: Map<number, TaskItemForClient> = new Map();
|
||||
|
||||
result.rows.forEach((t) => {
|
||||
if (!map.get(t.id)) {
|
||||
map.set(t.id, new TaskItemForClient(t));
|
||||
}
|
||||
});
|
||||
|
||||
return [...map.values()];
|
||||
return result.map((task) => ({
|
||||
...task,
|
||||
tags: [],
|
||||
assignedUsers: [],
|
||||
historyId: null,
|
||||
subtasks: [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type SearchTaskArgs = {
|
||||
description: string;
|
||||
goalsIds: number[];
|
||||
};
|
||||
|
||||
export type SearchTaskResult = TasksSchemaTypeForSelect & {
|
||||
tags: number[];
|
||||
assignedUsers: number[];
|
||||
historyId: number | null;
|
||||
subtasks: SearchTaskResult[];
|
||||
};
|
||||
|
||||
export type FetchAllListsResult = {
|
||||
goalName: string | null;
|
||||
listName: string | null;
|
||||
|
||||
@@ -44,6 +44,8 @@ const firstDayOfWeekArkType = type('number.integer').narrow((v, ctx) =>
|
||||
|
||||
export const UiSettingsArkType = type({
|
||||
'firstDayOfWeek?': firstDayOfWeekArkType,
|
||||
'defaultProjectId?': 'number.integer >= 1',
|
||||
'defaultView?': "'tasks' | 'kanban' | 'graph' | 'sprints'",
|
||||
})
|
||||
|
||||
export type UiSettings = typeof UiSettingsArkType.infer
|
||||
|
||||
@@ -22,6 +22,26 @@ export const AppEnvSchema = z.object({
|
||||
SMTP_FROM_NAME: z.string().optional(),
|
||||
SMTP_FROM_EMAIL: z.string().optional(),
|
||||
APP_URL: z.string(),
|
||||
|
||||
// Send an email to a person when they are invited to a project (requires SMTP); default off
|
||||
INVITE_EMAIL_ENABLED: z.string().optional(),
|
||||
// Max invite emails one user may trigger per hour (default 30)
|
||||
INVITE_EMAIL_HOURLY_LIMIT: z.string().optional(),
|
||||
|
||||
// How account password changes are confirmed: code sent by email (default) or current password
|
||||
PASSWORD_CHANGE_CONFIRMATION: z.enum(['email', 'password']).optional(),
|
||||
|
||||
// Comma-separated list of enabled login methods (magic-link, password, sso, social); unset = all enabled
|
||||
AUTH_LOGIN_METHODS: z.string().optional(),
|
||||
|
||||
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
|
||||
|
||||
@@ -62,6 +62,67 @@ export const ChangePasswordDataScheme = z
|
||||
|
||||
export type ChangePasswordData = z.infer<typeof ChangePasswordDataScheme>;
|
||||
|
||||
export const ChangeOwnPasswordSchema = z
|
||||
.object({
|
||||
code: z.string().min(1).max(64),
|
||||
password: z.string().min(6).max(128),
|
||||
passwordRepeat: z.string().max(128),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ['passwordRepeat'],
|
||||
});
|
||||
|
||||
export type ChangeOwnPassword = z.infer<typeof ChangeOwnPasswordSchema>;
|
||||
|
||||
export const ChangeOwnPasswordByPasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
password: z.string().min(6).max(128),
|
||||
passwordRepeat: z.string().max(128),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ['passwordRepeat'],
|
||||
});
|
||||
|
||||
export type ChangeOwnPasswordByPassword = z.infer<typeof ChangeOwnPasswordByPasswordSchema>;
|
||||
|
||||
export type PasswordChangeConfirmationMode = 'email' | 'password';
|
||||
|
||||
export type LoginMethod = 'magic-link' | 'password' | 'sso' | 'social';
|
||||
|
||||
export const ChangeDefaultUserCredentialsSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
login: z.string().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/).toLowerCase(),
|
||||
email: z.string().email().max(255).toLowerCase(),
|
||||
password: z.string().min(6).max(128),
|
||||
passwordRepeat: z.string().max(128),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ['passwordRepeat'],
|
||||
});
|
||||
|
||||
export type ChangeDefaultUserCredentials = z.infer<typeof ChangeDefaultUserCredentialsSchema>;
|
||||
|
||||
export type UpdateUserCredentialsArgs = {
|
||||
userId: number;
|
||||
oldEmail: string;
|
||||
login: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
export type UpdateUserEmailArgs = {
|
||||
userId: number;
|
||||
oldEmail: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
|
||||
|
||||
export const RefreshTokenSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,17 @@ import { randomInt } from 'crypto';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
|
||||
// so a user-controlled value can't break out of the surrounding markup.
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function isEmail(email: string): boolean {
|
||||
const re =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
@@ -18,6 +29,15 @@ export function generateString(length: number) {
|
||||
return result
|
||||
}
|
||||
|
||||
export function generateLetters(length: number) {
|
||||
let result = ''
|
||||
const characters = 'abcdefghijklmnopqrstuvwxyz'
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(randomInt(characters.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function time() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
@@ -37,9 +37,18 @@ cd web
|
||||
bash build-docker-web.sh $VERSION
|
||||
cd ..
|
||||
|
||||
# Build CE MCP
|
||||
echo "========================================="
|
||||
echo "Building CE MCP Server..."
|
||||
echo "========================================="
|
||||
cd taskview-packages/taskview-mcp
|
||||
bash build-docker-mcp.sh $VERSION gimanhead/taskview-ce-mcp
|
||||
cd ../..
|
||||
|
||||
echo "========================================="
|
||||
echo "Build complete!"
|
||||
echo "Images built:"
|
||||
echo " - gimanhead/taskview-ce-api-server:$VERSION"
|
||||
echo " - gimanhead/taskview-ce-webapp:$VERSION"
|
||||
echo " - gimanhead/taskview-ce-mcp:$VERSION"
|
||||
echo "========================================="
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: What is TaskView
|
||||
description: TaskView is an open-source, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
|
||||
description: TaskView is a source-available, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
|
||||
navigation:
|
||||
icon: i-lucide-house
|
||||
---
|
||||
|
||||
@@ -40,7 +40,12 @@ DB_USER="taskview_db_user"
|
||||
DB_PASSWORD="your_secure_password"
|
||||
DB_NAME="taskviewdb"
|
||||
DB_PORT=5432
|
||||
# Postgres connections per API worker (this is the default)
|
||||
DB_POOL_MAX=20
|
||||
APP_PORT=1401
|
||||
# API worker processes (this is the default). Accepts a number or "max" (one worker per CPU core).
|
||||
# Keep PM2_INSTANCES x DB_POOL_MAX below the Postgres max_connections limit (default 100).
|
||||
PM2_INSTANCES=2
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="secret"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
@@ -144,8 +149,24 @@ services:
|
||||
taskview-webapp:
|
||||
image: gimanhead/taskview-ce-webapp:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# The web app will always use this API server and hide the server selector on the login page.
|
||||
# Remove this variable if you want to pick the API server manually on the login page.
|
||||
TASKVIEW_API_URL: "http://localhost:1725"
|
||||
ports:
|
||||
- "8888:80"
|
||||
# Enable to let AI assistants (Claude Code, Cursor, ...) work with your instance
|
||||
# over MCP, read https://taskview.tech/docs/integrations/mcp
|
||||
# taskview-mcp:
|
||||
# image: gimanhead/taskview-ce-mcp:latest
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# TASKVIEW_URL: "http://taskview-api-server:1401"
|
||||
# ports:
|
||||
# - "3100:3100"
|
||||
# depends_on:
|
||||
# - taskview-api-server
|
||||
# networks: [backend]
|
||||
# Enable for realtime notification read https://taskview.tech/docs/configuration/environment-variables#centrifugo-configuration-file
|
||||
# centrifugo:
|
||||
# image: centrifugo/centrifugo:v6
|
||||
@@ -175,14 +196,16 @@ Go to [http://localhost:8888](http://localhost:8888) in your browser. You'll see
|
||||
|
||||
### Configure the API server
|
||||
|
||||
Before logging in, you need to tell the web app where the API server is running. Click the **server settings** icon on the login page and add the API server URL:
|
||||
The web app (port 8888) serves the frontend, while the API server (port 1725) handles authentication, projects, tasks, and all backend operations.
|
||||
|
||||
If you set `TASKVIEW_API_URL` on the `taskview-webapp` service (as in the compose file above), there is nothing to configure — the web app already knows where the API is, and the server selector is hidden from the login page.
|
||||
|
||||
Without `TASKVIEW_API_URL`, click the **server settings** section on the login page and add the API server URL manually:
|
||||
|
||||
```
|
||||
http://localhost:1725
|
||||
```
|
||||
|
||||
This is the API server that handles authentication, projects, tasks, and all backend operations. The web app (port 8888) serves the frontend, while the API server (port 1725) handles the data.
|
||||
|
||||
### Log in with the default user
|
||||
|
||||
The database migration creates a default user so you can log in right away:
|
||||
@@ -193,31 +216,25 @@ The database migration creates a default user so you can log in right away:
|
||||
Use these credentials to verify that everything is working - check that the UI loads, you can create a project, add tasks, etc.
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="error"}
|
||||
**Important:** The default user is for initial setup only. Once you've confirmed the system works, delete the default user and create your own account with a secure password.
|
||||
**Important:** The default credentials are publicly known — anyone who has read this page can sign in to a fresh installation. Claim the account right after the first login.
|
||||
::
|
||||
|
||||
### Replacing the default user
|
||||
### Claim the default account
|
||||
|
||||
Make the default account your own — no SMTP or database access needed:
|
||||
|
||||
1. Log in with the default credentials
|
||||
2. Register a new account with your real email and a strong password
|
||||
3. Delete the default `admin` account
|
||||
2. Open **Account settings** — the highlighted **Login and email** card is shown at the top (it is visible only to the default user)
|
||||
3. Set your own login, email and a strong password, confirm with the current password (`user1!#Q`), and click **Save and sign out**
|
||||
4. Sign in again with your new login and password
|
||||
|
||||
If you prefer to create the first user directly in the database, generate a password hash:
|
||||

|
||||
|
||||
```ts
|
||||
import { hashSync } from 'bcryptjs'
|
||||
Your organizations, projects and permissions are preserved. Once the email is changed, the card disappears and the claim endpoint is disabled.
|
||||
|
||||
const passwordHash = hashSync('your-secure-password', 12)
|
||||
console.log(passwordHash)
|
||||
```
|
||||
|
||||
Or as a one-liner:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('bcryptjs').hashSync('your-secure-password', 12))"
|
||||
```
|
||||
|
||||
Then insert the user into the database with the generated hash.
|
||||
::callout{icon="i-lucide-mail" color="info"}
|
||||
Changing the password later requires a confirmation code sent by email. If your installation has no SMTP, set `PASSWORD_CHANGE_CONFIRMATION="password"` in `.env.taskview` so password changes are confirmed with the current password instead. See [Environment Variables](/docs/configuration/environment-variables#authentication).
|
||||
::
|
||||
|
||||
## Updating
|
||||
|
||||
@@ -233,7 +250,10 @@ The migration container will automatically apply any new database changes on sta
|
||||
## Production tips
|
||||
|
||||
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
|
||||
- **Update `APP_URL` and `API_URL`** in `.env.taskview` to match your production domain
|
||||
- **Update `APP_URL`** in `.env.taskview` and `TASKVIEW_API_URL` on the webapp service to match your production domains
|
||||
- **Trim the login page** — set `AUTH_LOGIN_METHODS` in `.env.taskview` to offer only the sign-in methods you actually use (e.g. `AUTH_LOGIN_METHODS="password"`). Google/GitHub/Apple buttons are shown only when the provider is configured.
|
||||
- **Close the instance** — set `ALLOW_PUBLIC_REGISTRATION="false"` so strangers can't create accounts: only emails invited to an organization or project (and users coming through your SSO provider) can sign in and get an account on first login. See [how it works](/docs/configuration/environment-variables#closing-an-instance-how-allow_public_registrationfalse-works).
|
||||
- **Scale API workers deliberately** — the API runs `PM2_INSTANCES` worker processes (default `2`), and each worker opens its own pool of up to `DB_POOL_MAX` Postgres connections (default `20`). Before raising either value (or using `PM2_INSTANCES=max`), make sure `workers × DB_POOL_MAX` stays below your Postgres `max_connections` (default `100`) — otherwise the API fails with *"sorry, too many clients already"*. The API logs a warning on startup when the budget looks too high.
|
||||
- **Back up the database** - the `pgdata` volume contains all your data
|
||||
- **Set `restart: unless-stopped`** on all services so they survive server reboots
|
||||
- **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details.
|
||||
|
||||
@@ -50,7 +50,7 @@ Only **owners** and **admins** can manage members. The **Members** tab is not vi
|
||||
2. Go to the **Members** tab
|
||||
3. Enter an email address and click **Add Member**
|
||||
|
||||
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members can only be invited by email. The person needs to have a TaskView account with that email.
|
||||
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members are invited by email address. The person doesn't need a TaskView account yet - membership is stored against the email, so you can add someone in advance and they join the organization as soon as they sign up with that address.
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="warning"}
|
||||
When a project member with the **Manage users** permission invites someone into a project, that person is automatically added to the organization as a **member** - even though only admins and owners can add members directly. This is by design: a person can't be in a project without being in its organization. The auto-added member gets the minimum role and can't manage the organization.
|
||||
@@ -82,3 +82,21 @@ Organizations and projects have separate permission systems:
|
||||
Being an organization admin doesn't automatically give you permissions inside projects. You still need to be added to each project and assigned a project role. See [Roles and Permissions](/docs/collaboration/roles-and-permissions) for project-level access control.
|
||||
|
||||
The member list API endpoint is restricted to owners and admins. Regular members cannot fetch the list of organization members.
|
||||
|
||||
## Who can see which projects
|
||||
|
||||
| Who | Sees |
|
||||
|-----|------|
|
||||
| **Organization owner** | **Every project of the organization**, including projects created by other members |
|
||||
| **Organization admin** | Only the projects they were added to |
|
||||
| **Organization member** | Only the projects they were added to |
|
||||
|
||||
Projects created inside an organization belong to the organization, not to the person who created them: the organization owner is recorded as their owner. That is what keeps a project reachable when the person who created it leaves the company - nothing is lost with them. The flip side is that the owner sees every project of their organization, whoever created it.
|
||||
|
||||
Everyone else - admins included - gets access to a project only by being added to it and given a project role. Being an organization admin means administering the organization (members, settings, SSO), not its content.
|
||||
|
||||
::callout{icon="i-lucide-shield-alert" color="warning"}
|
||||
**Access is granted explicitly, never inherited from a title.** There is deliberately no "admins can see all projects" switch: if a project should be visible to someone, they get invited to it. This keeps sensitive projects - finance, HR, salaries - private by default instead of silently opening them the moment someone is promoted to admin.
|
||||
|
||||
The one exception is the organization owner, who sees everything by design (see above). If a project must stay private from the owner too, keep it in your **personal workspace** rather than in the organization.
|
||||
::
|
||||
|
||||
@@ -22,6 +22,23 @@ Users who sign in via SSO are automatically added to the organization that owns
|
||||
|
||||
Go to your organization's settings → **SSO** tab. You need the **admin** or **owner** role.
|
||||
|
||||
### Domain verification
|
||||
|
||||
SSO login stays off until the organization proves it owns the email domain (so another org on a shared instance cannot claim `gmail.com` or your company domain).
|
||||
|
||||
After you save the SSO config, TaskView shows a verification token. Use **one** of:
|
||||
|
||||
1. **DNS TXT** — add a TXT record on the domain:
|
||||
`taskview-sso-verify=<token>`
|
||||
2. **HTTP file** — serve the token (plain text) at:
|
||||
`https://<domain>/.well-known/taskview-sso-verify.txt`
|
||||
|
||||
Then click **Check domain**. Either method is enough. After a successful check, SSO login is enabled.
|
||||
|
||||
**Closed-network / air-gapped installs:** you may not have public DNS. Set `SSO_TRUSTED_DOMAINS=company.com,corp.local` on the API server. Domains in that list skip the DNS/HTTP check and are treated as verified.
|
||||
|
||||
Existing SSO configs created before this check are not verified: logins stop until an admin completes verification or the domain is listed in `SSO_TRUSTED_DOMAINS`.
|
||||
|
||||
### SAML 2.0
|
||||
|
||||
**Required fields:**
|
||||
@@ -35,6 +52,10 @@ Go to your organization's settings → **SSO** tab. You need the **admin** or **
|
||||
| IdP Certificate | Your IdP's public signing certificate (base64, without BEGIN/END headers) |
|
||||
| ACS URL (Callback) | The URL where your IdP sends SAML responses. Shown after creating the config - copy it to your IdP |
|
||||
|
||||
::callout{icon="i-lucide-network" color="warning"}
|
||||
**Running behind a reverse proxy?** Set [`API_PUBLIC_URL`](/docs/configuration/environment-variables#application) to the public address of your API server. The ACS/Callback URL and the SCIM endpoint shown on this screen are built from it — without the variable they fall back to the address your browser used, which behind a proxy can be an internal host that your IdP cannot reach.
|
||||
::
|
||||
|
||||
**Using Metadata URL (recommended):**
|
||||
|
||||
Instead of filling fields manually, paste your IdP's metadata URL and click **Sync**. This auto-fills the IdP SSO URL, Certificate, and Logout URL from the metadata XML.
|
||||
@@ -141,6 +162,8 @@ Request IDs expire after 5 minutes.
|
||||
| POST | `/module/sso/admin/configs` | Create SSO config |
|
||||
| PATCH | `/module/sso/admin/configs/{configId}` | Update SSO config |
|
||||
| DELETE | `/module/sso/admin/configs/{configId}` | Delete SSO config |
|
||||
| POST | `/module/sso/admin/configs/{configId}/verify-domain` | Return DNS TXT and HTTP well-known proof for the domain |
|
||||
| POST | `/module/sso/admin/configs/{configId}/verify-domain/check` | Check DNS TXT then HTTP file; enable SSO on success |
|
||||
|
||||
## Database tables
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: UI Customization
|
||||
description: Personalize TaskView per user - reorder and hide task fields and analytics blocks, set the first day of week, and pick a default project and view to open right after signing in.
|
||||
navigation:
|
||||
icon: i-lucide-sliders-horizontal
|
||||
---
|
||||
|
||||
TaskView lets every user tune the interface to how they actually work. All settings on this page are personal — they are stored per user on the server and follow you across devices and browsers.
|
||||
|
||||
Open **Settings → UI customization** from the user menu.
|
||||
|
||||
## Reorder and hide items
|
||||
|
||||
Three sections are driven by drag-and-drop lists:
|
||||
|
||||
- **Tasks** — the fields shown in the task detail view (note, status, priority, assignees, tags, deadline, sprint, estimate, time tracking, history, …)
|
||||
- **Analytics — Indicators** — the KPI tiles on the analytics page
|
||||
- **Analytics — Charts** — the charts on the analytics page
|
||||
|
||||
For every item you can:
|
||||
|
||||
- **Reorder** — drag by the handle on the left
|
||||
- **Show / hide** — toggle visibility
|
||||
- **Narrow / wide** — for task fields, choose whether the field takes half or the full width of the detail view
|
||||
|
||||
All available items are listed here; permission checks still apply at render time, so an enabled item may stay hidden if you lack the permission to see it.
|
||||
|
||||
## Others
|
||||
|
||||
### First day of week
|
||||
|
||||
Sets which day calendars start on. Applies to all date pickers across the app. "Default" follows your locale.
|
||||
|
||||
### Default project and view
|
||||
|
||||
Normally, after signing in you land on the home screen and navigate to your project and board manually. If you always start in the same place, set it as the default:
|
||||
|
||||
- **Default project** — the project TaskView opens right after you sign in (or reopen the app with an active session)
|
||||
- **Default view** — which view of that project to open: **Tasks**, **Kanban**, **Graph** or **Sprints**
|
||||
|
||||
When a default project is set, a quick-jump button with the project name also appears in the sidebar next to **Inbox** — click it from anywhere to return to your project in the chosen view.
|
||||
|
||||
If the default project is deleted or you lose access to it, TaskView falls back to the home screen. Choose "Home screen (default)" to turn the feature off.
|
||||
|
||||
## How it is stored
|
||||
|
||||
Preferences are saved automatically (no Save button) through the `ui-preferences` API and kept per user account. Resetting the browser or switching devices does not lose them.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: GitHub & GitLab Setup
|
||||
description: Connect GitHub and GitLab repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise and self-hosted GitLab.
|
||||
title: GitHub, GitLab & Gitea Setup
|
||||
description: Connect GitHub, GitLab and Gitea repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise, self-hosted GitLab and self-hosted Gitea.
|
||||
navigation:
|
||||
icon: i-lucide-git-pull-request
|
||||
---
|
||||
|
||||
TaskView integrations allow you to connect GitHub or GitLab repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
TaskView integrations allow you to connect GitHub, GitLab or Gitea repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -81,7 +81,29 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
|
||||
---
|
||||
|
||||
## 5. Full `.env.taskview` Example
|
||||
## 5. Create Gitea OAuth App (optional)
|
||||
|
||||
1. On [gitea.com](https://gitea.com) (or your own instance) go to **Settings → Applications → Manage OAuth2 Applications**
|
||||
2. Click **"Create Application"**
|
||||
3. Fill in:
|
||||
- **Application Name**: `TaskView Integrations`
|
||||
- **Redirect URIs**: `http://localhost:1401/module/integrations/oauth/gitea/callback`
|
||||
4. Click **"Create Application"**
|
||||
5. Copy **Client ID** and **Client Secret**
|
||||
|
||||
Add to `.env.taskview`:
|
||||
|
||||
```
|
||||
GITEA_INTEGRATION_CLIENT_ID=<your-client-id>
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=<your-client-secret>
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
```
|
||||
|
||||
> **Note**: For self-hosted Gitea, also set `GITEA_BASE_URL=https://gitea.yourcompany.com` (defaults to `https://gitea.com`). The API URL is derived as `{GITEA_BASE_URL}/api/v1`; override with `GITEA_API_URL` only if it's served from a different address.
|
||||
|
||||
---
|
||||
|
||||
## 6. Full `.env.taskview` Example
|
||||
|
||||
```env
|
||||
# ... existing vars ...
|
||||
@@ -98,16 +120,21 @@ GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
GITLAB_INTEGRATION_CLIENT_ID=app_id_123
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
|
||||
# Gitea Integration OAuth (optional)
|
||||
GITEA_INTEGRATION_CLIENT_ID=client_id_123
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage
|
||||
## 7. Usage
|
||||
|
||||
1. Open a project in TaskView
|
||||
2. Right-click the project in the sidebar → **"Integrations"**
|
||||
3. Click **"Add Integration"**
|
||||
4. Choose **GitHub** or **GitLab** - you'll be redirected to authorize
|
||||
4. Choose **GitHub**, **GitLab** or **Gitea** - you'll be redirected to authorize
|
||||
5. After authorization, select a repository from the list
|
||||
6. Done - the integration is active
|
||||
|
||||
@@ -119,7 +146,7 @@ You can toggle integrations on/off or delete them from the integrations page.
|
||||
|
||||
- **Callback URLs**: Update to your production domain (e.g., `https://api.yourdomain.com/module/integrations/oauth/github/callback`)
|
||||
- **ENCRYPTION_KEY**: Store securely, never commit to git. If changed, existing encrypted tokens become unreadable
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab OAuth Apps for production with production callback URLs
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab/Gitea OAuth Apps for production with production callback URLs
|
||||
- **CORS**: Ensure your production frontend domain is in `CORS_ALLOWED_ORIGINS`
|
||||
|
||||
---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user