Compare commits

..

81 Commits

Author SHA1 Message Date
Nikolai Giman 5976a03ce0 wip: invoices 2026-09-07 00:30:43 +02:00
Nikolai Giman 3403e1a71d wip: OAuth and mcp 2026-09-06 18:06:35 +02:00
Nikolai Giman e9fb4f6f1e fix: org permissions 2026-08-31 00:54:19 +02:00
Nikolai Giman a173a870d7 feat: OAuth 2026-08-30 23:39:25 +02:00
Nikolai Giman 171f269ee2 version: 1.53.0 2026-08-27 20:42:36 +02:00
Nikolai Giman f79614ebc5 fix(security): enforce collaboration access permissions 2026-08-27 20:29:54 +02:00
Nikolai Giman a4c465469d wip: updated locales 2026-08-26 23:41:46 +02:00
Nikolai Giman f3e2663ebe Merge branch 'main' into fix/issues 2026-08-26 23:35:04 +02:00
Nikolai Giman 5ce2fe99f0 Merge pull request #108 from murilorosa/feat/pt-BR_locale
feat: add pt-BR locale
2026-08-26 23:27:46 +02:00
Nikolai Giman e11aba4d79 fix: time tracking edit 2026-08-26 23:20:16 +02:00
Murilo Biassio Rosa 0b6f95ec3d feat: add pt-BR locale 2026-08-25 22:21:10 -03:00
Nikolai Giman a596e023db fix: performance issue in graph and update layout building 2026-08-24 22:05:39 +02:00
Nikolai Giman c1685e42b8 feat: task dependencies in detailed panel 2026-08-24 14:21:46 +02:00
Nikolai Giman 9635120e66 fix: add security md 2026-08-23 17:56:17 +02:00
Nikolai Giman 19e0212edf fix: add task on the main screen when no project is created 2026-08-22 22:06:52 +02:00
Nikolai Giman 5dc5b387de fix: disable analytics 2026-08-22 16:43:48 +02:00
Nikolai Giman 8011da1259 fix: #106 2026-08-22 09:01:33 +02:00
Nikolai Giman a6329e998d fix: disabled analytics 2026-08-21 22:51:57 +02:00
Nikolai Giman b372854636 fix: custom server url normalization 2026-08-21 10:16:22 +02:00
Nikolai Giman a243f9ec56 fix: normalize server url 2026-08-21 10:06:54 +02:00
Nikolai Giman ebd25a94ea fix: sso email resolving and switcher for sso 2026-08-20 19:53:29 +02:00
Nikolai Giman 4b142619c0 fix: #6126 2026-08-20 16:13:23 +02:00
Nikolai Giman 6f576e7297 Merge pull request #104 from Gimanh/fix/issues
chore: version
2026-08-20 14:44:26 +02:00
Nikolai Giman 15050541fa chore: version 2026-08-20 14:40:23 +02:00
Nikolai Giman 3afdca99d9 Merge pull request #103 from Gimanh/fix/issues
fix: sso domain verification and #77
2026-08-20 14:37:05 +02:00
Nikolai Giman aaa876412d fix: #101 2026-08-20 13:51:32 +02:00
Nikolai Giman 915e8d9f9f fix: sso domain verification and #77 2026-08-18 18:57:17 +02:00
Nikolai Giman 55ded8bac9 Merge pull request #99 from Gimanh/fix/98
fix: #98
2026-08-04 20:45:47 +02:00
Nikolai Giman 784652ef5b chore: version 2026-08-04 20:44:27 +02:00
Nikolai Giman 1f1a1b770f fix: #98 2026-08-04 20:40:31 +02:00
Nikolai Giman e80ab33dda Merge pull request #97 from Gimanh/feat/http-mcp
feat: http mcp
2026-08-03 00:38:50 +02:00
Nikolai Giman 8c7be7362f feat: http mcp 2026-08-03 00:33:13 +02:00
Nikolai Giman 57ec7c01b6 Merge pull request #96 from Gimanh/feat/email-notification
feat: email notification
2026-08-02 19:10:15 +02:00
Nikolai Giman d0f664f78e feat: email notification 2026-08-02 10:11:22 +02:00
Nikolai Giman 7bbb36d45e Merge pull request #94 from Gimanh/feat/gitea-integration
feat: gitea integration
2026-07-28 19:54:07 +02:00
Nikolai Giman 9934bf06d8 feat: gitea integration 2026-07-28 00:45:50 +02:00
Nikolai Giman fd13b33915 Merge pull request #93 from Gimanh/tests/e2e
tests: add attrs
2026-07-26 19:12:27 +02:00
Nikolai Giman 64089fd6e7 tests: add attrs 2026-07-25 23:09:08 +02:00
Nikolai Giman b7a50049bb Merge pull request #92 from Gimanh/feat/4745-public-api-url
feat: public API URL fix for sso callback
2026-07-18 14:53:34 +02:00
Nikolai Giman 504ae503dd feat: public API URL fix for sso callback 2026-07-18 14:53:00 +02:00
Nikolai Giman 263883f32d Merge pull request #91 from Gimanh/feat/5047-disable-public-registration
feat: ALLOW_PUBLIC_REGISTRATION
2026-07-18 14:18:16 +02:00
Nikolai Giman 90b55fd82a feat: ALLOW_PUBLIC_REGISTRATION 2026-07-18 14:15:28 +02:00
Nikolai Giman 2e9a4945ce Merge pull request #89 from Gimanh/chore/refactor
chore: bootstrap
2026-07-18 10:55:01 +02:00
Nikolai Giman 0d70023fb3 Merge pull request #90 from Gimanh/fix/gh-88-pm2-db-connections
fix: #88
2026-07-16 22:17:46 +02:00
Nikolai Giman 645f2e21e5 fix: #88 2026-07-16 22:16:41 +02:00
Nikolai Giman adce016a05 chore: bootstrap 2026-07-16 19:38:24 +02:00
Nikolai Giman b7f2380d45 Merge pull request #87 from Gimanh/fix/5315-logout-state-reset
fix: clear stores on logout
2026-07-15 21:24:14 +02:00
Nikolai Giman 58eb93e564 fix: clear stores on logout 2026-07-15 21:23:26 +02:00
Nikolai Giman 4e5e5fb579 Merge pull request #86 from Gimanh/feat/recurring-by-completion
wip: recurring
2026-07-14 17:46:05 +02:00
Nikolai Giman 314e5a6377 merge: resolve 2026-07-13 20:27:25 +02:00
Nikolai Giman 955697f40f Merge branch 'main' into feat/recurring-by-completion 2026-07-13 20:23:52 +02:00
Nikolai Giman e5dbba8e2a wip: recurring 2026-07-13 20:15:55 +02:00
Nikolai Giman bb28bac9f7 Merge pull request #85 from Gimanh/chore/ver-1-50-2
chore: 1.50.2
2026-07-12 22:50:30 +02:00
Nikolai Giman 6c990aff4f chore: 1.50.2 2026-07-12 22:49:43 +02:00
Nikolai Giman b296046605 Merge pull request #84 from Gimanh/fix/83-sso-login-by-code-gate
fix: allow login-by-code redemption for SSO and social logins (#83)
2026-07-12 22:46:15 +02:00
Nikolai Giman 40cd8064f5 fix: allow login-by-code redemption for SSO and social logins (#83)
SSO and social OAuth callbacks complete the login by redeeming a
one-time code through POST /module/auth/login-by-code, but the endpoint
was gated behind RequireLoginMethod('magic-link'). With
AUTH_LOGIN_METHODS excluding magic-link (e.g. "sso"), the redemption
returned an empty 403 and the user silently bounced back to the login
page.

- Add RequireAnyLoginMethod and gate /login-by-code on
  magic-link OR sso OR social
- Show a toast on failed code redemption instead of only logging
  to console
2026-07-12 22:44:23 +02:00
Nikolai Giman f4a765f40b Merge pull request #82 from Gimanh/chore/ver-1-50
chore: version
2026-07-11 23:25:56 +02:00
Nikolai Giman b9ce7261f0 chore: version 2026-07-11 23:25:00 +02:00
Nikolai Giman bc08c839bc Merge pull request #81 from Gimanh/fix/72-ui-and-setup
Fix/72 UI and setup
2026-07-11 23:15:50 +02:00
Nikolai Giman 83979ac47c chore: docs 2026-07-11 22:39:47 +02:00
Nikolai Giman a9be0d987a feat: set default project 2026-07-11 22:18:36 +02:00
Nikolai Giman 30274069a0 feat: set base api url from env 2026-07-11 21:49:10 +02:00
Nikolai Giman 0a8497af59 feat: edit default user and change password 2026-07-11 20:29:27 +02:00
Nikolai Giman 284a49ce8c fix: task id and search 2026-07-11 14:43:52 +02:00
Nikolai Giman 64a227303e Merge pull request #80 from Gimanh/feat/mobile-widgets
feat: mobile widgets
2026-07-11 10:59:14 +02:00
Nikolai Giman ae88d0f42a feat: mobile widgets 2026-07-11 10:49:38 +02:00
Nikolai Giman d40cc0aa1b Merge pull request #79 from Gimanh/fix/settings-ui
chore: version
2026-07-07 23:23:08 +02:00
Nikolai Giman e99d9d5515 chore: version 2026-07-07 23:20:02 +02:00
Nikolai Giman 9b38e3cd9d Merge pull request #78 from Gimanh/fix/77
fix: #77
2026-07-07 00:19:14 +02:00
Nikolai Giman 9c6d33cefe fix: #77 2026-07-07 00:16:26 +02:00
Nikolai Giman d008fa4f78 Merge pull request #75 from Gimanh/chore/v-1-49-0
chore: version 1.49.0
2026-07-06 00:11:07 +02:00
Nikolai Giman 8ff7241645 chore: version 1.49.0 2026-07-06 00:09:49 +02:00
Nikolai Giman 9bdfe679bd Merge pull request #74 from Gimanh/fix/70-oidc-login-nonce
fix: oidc login
2026-07-05 23:30:11 +02:00
Nikolai Giman 4e5a330a27 fix: oidc login 2026-07-05 23:28:51 +02:00
Nikolai Giman e5860a3816 Merge pull request #73 from Gimanh/feat/messaging-integrations
feat: slack and tg integration
2026-07-05 21:37:12 +02:00
Nikolai Giman 0f4c484674 feat: slack and tg integration 2026-07-05 17:42:51 +02:00
Nikolai Giman e025c212e5 Merge pull request #69 from Gimanh/feat/inbox
feat: inbox
2026-06-29 00:55:03 +02:00
Nikolai Giman 9b08c82548 feat: inbox 2026-06-29 00:53:53 +02:00
Nikolai Giman 3d9cb5336a Merge pull request #68 from Gimanh/chore/readme.md
chore: readme
2026-06-28 16:09:30 +02:00
Nikolai Giman 8eb94d0d2b chore: readme 2026-06-28 16:09:08 +02:00
Nikolai Giman 78c910f789 Merge pull request #67 from Gimanh/feat/ui-refactor
feat: ui improvements
2026-06-28 15:07:16 +02:00
522 changed files with 30463 additions and 1920 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "taskview",
"owner": {
"name": "Nikolai Giman"
},
"plugins": [
{
"name": "taskview",
"source": "./taskview-plugin",
"description": "Manage TaskView projects and tasks from Claude Code. Bundles the TaskView MCP server, skills, and slash commands."
}
]
}
+2
View File
@@ -5,6 +5,8 @@ ReleasesBuilds/*
iconcreator
*.prod.*
.DS_Store
.cursor
.claude
node_modules
__APP_BUILD__
ssl-create
+43 -4
View File
@@ -1,7 +1,7 @@
# TaskView™
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView — Kanban board" width="1440" style="max-width: 100%;">
</p>
<p align="center">
@@ -14,7 +14,7 @@
</p>
<details>
<summary><strong>View more screenshots</strong></summary>
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
<br>
@@ -60,6 +60,15 @@
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
</a>
<a href="https://github.com/Gimanh/taskview-community/releases">
<img src="https://img.shields.io/github/v/release/Gimanh/taskview-community?label=release&color=brightgreen" alt="Latest release">
</a>
<a href="https://www.npmjs.com/package/taskview-mcp">
<img src="https://img.shields.io/npm/v/taskview-mcp?label=taskview-mcp&logo=npm&color=CB3837" alt="taskview-mcp on npm">
</a>
<a href="https://www.npmjs.com/package/taskview-api">
<img src="https://img.shields.io/npm/v/taskview-api?label=taskview-api&logo=npm&color=CB3837" alt="taskview-api on npm">
</a>
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
</p>
@@ -169,7 +178,37 @@ Depending on the permissions assigned to an API token, an AI assistant can:
MCP access can be restricted by permission and by selected projects.
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
### Connecting an MCP client
The MCP server is published as [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) and runs over stdio via `npx` — no install required. You only need a TaskView API token (`tvk_...`) — generate one in your account settings (see [API tokens](https://taskview.tech/docs/features/api-tokens)). Scope the token to the minimum permissions and projects the assistant should reach.
**Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global):
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
**Claude Desktop** — add the same `mcpServers` block to `claude_desktop_config.json`.
**Other MCP clients** (Cursor, Windsurf, etc.) — use the same stdio command `npx -y taskview-mcp` with the `TASKVIEW_URL` and `TASKVIEW_TOKEN` environment variables in that client's MCP configuration.
| Variable | Required | Description |
|---|---|---|
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`, or your self-hosted instance) |
| `TASKVIEW_TOKEN` | yes | API token with the `tvk_` prefix |
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) and the [`taskview-mcp` package README](taskview-packages/taskview-mcp/README.md) for the full tool list and more options.
## Quick start
@@ -389,7 +428,7 @@ For commercial licensing questions, hosted service permissions, or other use cas
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately using the contact information provided in the repository or on the TaskView website.
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
When running TaskView in production:
+46
View File
@@ -0,0 +1,46 @@
# Security Policy
## Reporting a vulnerability
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report them privately using one of these channels:
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
Please include as much of the following as you can:
- A description of the issue and its impact
- Affected component (API, web app, MCP server, mobile app) and version
- Steps to reproduce, or a proof of concept
- Any suggested mitigation
## What to expect
- We will acknowledge your report within **5 business days**.
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
## Supported versions
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
## Scope
In scope:
- The TaskView API server, web app, MCP server, and mobile app in this repository
- The hosted service at `app.taskview.tech`
Out of scope:
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
- Findings that require a compromised admin account or physical access to the server
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
- Denial-of-service testing against the hosted service
## Safe harbor
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
+24
View File
@@ -19,6 +19,18 @@ 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
# OAuth 2.1 for third-party MCP clients (ChatGPT, Claude connectors).
# Dynamic Client Registration is on by default; a cloud client cannot connect
# without it, since it has no way to pre-register with your instance. Turn it
# off on a private install that only uses manually seeded clients.
#OAUTH_DYNAMIC_REGISTRATION=false
# Public URL of this API. Used as the OAuth issuer in the discovery documents,
# so it must be the URL clients actually reach — set it behind a proxy.
#API_PUBLIC_URL=https://api.taskview.tech
# SMTP Configuration
SMTP_HOST=smtp.domain.com
SMTP_PORT=465
@@ -27,6 +39,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 +64,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
View File
@@ -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,
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.48.4",
"version": "1.53.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -27,6 +27,7 @@
"@types/luxon": "^3.7.1",
"@types/node": "^22.10.3",
"@types/passport-apple": "^2.0.3",
"@types/pdfmake": "^0.3.3",
"@types/pg": "^8.15.5",
"@types/semver": "^7.5.8",
"@types/ua-parser-js": "^0.7.39",
@@ -68,6 +69,7 @@
"passport-apple": "^2.0.2",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"pdfmake": "^0.3.11",
"pg": "^8.16.3",
"pg-boss": "^12.14.0",
"pino": "^9.4.0",
@@ -83,4 +85,4 @@
"engines": {
"node": ">=24 <25"
}
}
}
+30 -3
View File
@@ -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
View File
@@ -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);
}
+2 -1
View File
@@ -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 };
+4
View File
@@ -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() {
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
import { RequireTokenPermission } from '../require-token-permission';
import { GoalPermissions } from '../../types/auth.types';
const runWith = (tokenPermissions: string[] | undefined) => {
const next = vi.fn();
const end = vi.fn();
const res = { status: vi.fn(() => ({ end })), end } as unknown as Response;
const req = { appUser: { getTokenPermissions: () => tokenPermissions } } as unknown as Request;
RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)(req, res, next);
return { next, res };
};
describe('RequireTokenPermission', () => {
it('lets a browser session through — it carries no token permissions', () => {
const { next, res } = runWith(undefined);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('lets an unrestricted token through, keeping existing integrations working', () => {
const { next } = runWith([]);
expect(next).toHaveBeenCalled();
});
it('lets a token holding the permission through', () => {
const { next } = runWith([GoalPermissions.ORG_CAN_MANAGE]);
expect(next).toHaveBeenCalled();
});
it('blocks a restricted token that was not given the permission', () => {
const { next, res } = runWith([GoalPermissions.TIMETRACKING_CAN_VIEW]);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not accept a neighbouring permission from the same group', () => {
const { next, res } = runWith([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { $logger } from '../modules/logget';
import AuthController from '../tv-modules/auth/AuthController';
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
import { OAUTH_ACCESS_TOKEN_PREFIX } from '../tv-modules/oauth/types';
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
if (req.method === 'OPTIONS') {
@@ -13,7 +14,7 @@ export const appUserMiddleware = async (req: Request, res: Response, next: NextF
const token = req.headers['authorization']?.split(' ')[1];
if (token && token.startsWith(TOKEN_PREFIX)) {
if (token && (token.startsWith(TOKEN_PREFIX) || token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX))) {
const record = await getApiTokensManager().validateToken(token);
if (record) {
const authManager = new AppUser().authManager;
@@ -0,0 +1,34 @@
import type { NextFunction, Request, Response } from 'express';
import type { GoalPermissionType } from '../types/auth.types';
/**
* Narrows what a restricted API / OAuth token may do on surfaces that are guarded
* by an organization role or by project ownership rather than by the project RBAC
* — organizations, SSO configuration, webhooks. Those checks never consult
* GoalPermissionsFetcher, so without this the scope chosen when the token was
* issued would simply not apply to them.
*
* It only ever removes access. Put it AFTER the role or ownership guard, so that
* guard still has the final say on what the human behind the token may do:
*
* [IsLoggedIn, IsOrgAdmin, RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)]
*
* A browser session has no token permissions and passes. A token issued with an
* empty permission list is unrestricted by design — the same meaning it carries
* everywhere else — and also passes, which keeps existing integrations working.
*/
export const RequireTokenPermission = (permission: GoalPermissionType) => {
return (req: Request, res: Response, next: NextFunction) => {
const tokenPermissions = req.appUser.getTokenPermissions();
if (!tokenPermissions || tokenPermissions.length === 0) {
return next();
}
if (tokenPermissions.includes(permission)) {
return next();
}
return res.status(403).end();
};
};
+161 -1
View File
@@ -640,5 +640,165 @@
"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)."
]
},
"59": {
"version": "1.64.0",
"name": "OAuth 2.1 authorization server",
"releaseDate": "20260830",
"scripts": [
"/1.64.0/0.create-oauth-clients.sql",
"/1.64.0/1.create-oauth-auth-codes.sql",
"/1.64.0/2.create-oauth-grants.sql",
"/1.64.0/3.alter-api-tokens-grant-id.sql"
],
"description": [
"OAuth 2.1 authorization server so third-party MCP clients (ChatGPT, Claude connectors) can act on a user's behalf without the user pasting a permanent tvk_ API token into them.",
"tv_auth.oauth_clients holds the client registry (manually seeded or created via RFC 7591 Dynamic Client Registration); public clients carry no secret and are authenticated by PKCE S256 alone.",
"tv_auth.oauth_auth_codes holds single-use 60-second authorization codes; tv_auth.oauth_grants is one row per connected app and owns the rotating refresh token, with the previous hash kept to detect replay.",
"Access tokens reuse tv_auth.api_tokens (new grant_id column) so validation, permission intersection and RejectApiTokenAuth all keep working unchanged; revoking a grant cascades to its live access tokens."
]
},
"60": {
"version": "1.65.0",
"name": "Organization-level permissions",
"releaseDate": "20260830",
"scripts": [
"/1.65.0/0.organization-permissions.sql"
],
"description": [
"New permission group 'organization' with org_can_view, org_can_manage, org_can_manage_members, sso_can_manage and webhooks_can_manage.",
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
]
},
"61": {
"version": "1.66.0",
"name": "Billing: currencies, sellers, clients, invoices",
"releaseDate": "20260904",
"scripts": [
"/1.66.0/0.create-currencies.sql",
"/1.66.0/1.create-billing-sellers.sql",
"/1.66.0/2.create-billing-counterparties.sql",
"/1.66.0/3.create-invoices.sql",
"/1.66.0/4.create-invoice-lines.sql",
"/1.66.0/5.billing-permission.sql",
"/1.66.0/6.alter-invoices-lifecycle.sql"
],
"description": [
"New schema tv_billing with the reference table currencies (ISO 4217, seeded with 20 currencies), sellers (the organization's own companies that issue invoices, with bank details and free-form requisites), counterparties (clients that are invoiced) and invoices with invoice_lines.",
"Invoices keep JSONB snapshots of the seller and the client plus the project name, so editing a company, a client or deleting a project never changes an issued document. Companies and clients are archived rather than deleted once they have invoices.",
"New organization-level permission billing_can_manage (group 6) narrows what an API or OAuth token may do on the billing surfaces; access itself requires the organization owner or admin role.",
"Invoice lifecycle: issued_at, paid_at, voided_at, replaces_invoice_id (void and reissue chain), template_version and totals frozen at issue time (subtotal, discount_amount, tax_amount, total). Status transitions are validated by the API; only drafts can be edited or deleted."
]
}
}
}
@@ -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,16 @@
-- OAuth 2.1 client registry. Clients are either seeded manually by an operator
-- or created through Dynamic Client Registration (RFC 7591) when it is enabled.
-- Public clients (MCP clients such as ChatGPT or Claude) hold no secret and are
-- authenticated by PKCE alone, so client_secret_hash stays NULL for them.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_clients (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
client_id VARCHAR(64) NOT NULL UNIQUE,
client_secret_hash VARCHAR(64),
name VARCHAR(200) NOT NULL,
redirect_uris VARCHAR[] NOT NULL DEFAULT '{}',
created_via VARCHAR(16) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT oauth_clients_created_via_check CHECK (created_via IN ('manual', 'dcr'))
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON tv_auth.oauth_clients(client_id);
@@ -0,0 +1,24 @@
-- Short-lived, single-use authorization codes issued by the consent screen and
-- redeemed once at the token endpoint. Only the hash is stored, mirroring
-- tv_auth.api_tokens. used_at is set on redemption: a second redemption of the
-- same code is treated as replay and revokes the grant it produced.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_auth_codes (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
code_hash VARCHAR(64) NOT NULL UNIQUE,
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
redirect_uri VARCHAR NOT NULL,
code_challenge VARCHAR(128) NOT NULL,
code_challenge_method VARCHAR(8) NOT NULL DEFAULT 'S256',
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
resource VARCHAR,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
grant_id INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT oauth_auth_codes_challenge_method_check CHECK (code_challenge_method = 'S256')
);
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code_hash ON tv_auth.oauth_auth_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires_at ON tv_auth.oauth_auth_codes(expires_at);
@@ -0,0 +1,33 @@
-- One row per (user, client) authorization — this is what the user sees and
-- revokes as a "connected app". The refresh token hangs off the grant and is
-- rotated on every use; refresh_token_prev_hash keeps the previous value so a
-- replayed refresh token can be detected and the whole grant revoked.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_grants (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
resource VARCHAR,
refresh_token_hash VARCHAR(64) UNIQUE,
refresh_token_prev_hash VARCHAR(64),
refresh_expires_at TIMESTAMP,
last_used_at TIMESTAMP,
revoked_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_user_id ON tv_auth.oauth_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_refresh_token_hash ON tv_auth.oauth_grants(refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_prev_refresh_hash ON tv_auth.oauth_grants(refresh_token_prev_hash);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'oauth_auth_codes_grant_id_fkey'
) THEN
ALTER TABLE tv_auth.oauth_auth_codes
ADD CONSTRAINT oauth_auth_codes_grant_id_fkey
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE SET NULL;
END IF;
END $$;
@@ -0,0 +1,19 @@
-- OAuth access tokens live in tv_auth.api_tokens alongside manually issued
-- tvk_ tokens: same opaque-token storage, same validation path, same permission
-- intersection. grant_id ties an access token to the OAuth grant that minted it,
-- so revoking a connected app deletes its live access tokens immediately.
ALTER TABLE tv_auth.api_tokens
ADD COLUMN IF NOT EXISTS grant_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_grant_id_fkey'
) THEN
ALTER TABLE tv_auth.api_tokens
ADD CONSTRAINT api_tokens_grant_id_fkey
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_api_tokens_grant_id ON tv_auth.api_tokens(grant_id);
@@ -0,0 +1,86 @@
-- Permissions for the surfaces that were previously guarded only by an
-- organization role or by project ownership, and therefore ignored the scope of
-- an API / OAuth token entirely: a token issued with a single permission could
-- still create organizations, add admins, configure SSO and create webhooks.
--
-- These keys let a token be narrowed on those actions too. They never grant
-- anything: the role and ownership checks still run first, and RequireTokenPermission
-- only removes what the token was not given.
INSERT INTO tv_auth.permissions_group (id, name)
VALUES (6, 'organization')
ON CONFLICT (id) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_view',
'View the organization and its members',
6,
'{
"en": "View organization. See the organization and the list of its members.",
"ru": "Просмотр организации. Видеть организацию и список её участников.",
"de": "Organisation ansehen. Die Organisation und ihre Mitglieder sehen.",
"es": "Ver la organización. Ver la organización y la lista de sus miembros.",
"pt-BR": "Ver a organização. Ver a organização e a lista de seus membros."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_manage',
'Create, rename and delete organizations',
6,
'{
"en": "Manage organizations. Create, rename and delete organizations.",
"ru": "Управление организациями. Создавать, переименовывать и удалять организации.",
"de": "Organisationen verwalten. Organisationen erstellen, umbenennen und löschen.",
"es": "Gestionar organizaciones. Crear, renombrar y eliminar organizaciones.",
"pt-BR": "Gerenciar organizações. Criar, renomear e excluir organizações."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_manage_members',
'Add and remove organization members and change their roles',
6,
'{
"en": "Manage members. Add and remove organization members and change their roles.",
"ru": "Управление участниками. Добавлять и удалять участников организации, менять их роли.",
"de": "Mitglieder verwalten. Mitglieder hinzufügen, entfernen und deren Rollen ändern.",
"es": "Gestionar miembros. Añadir y quitar miembros de la organización y cambiar sus roles.",
"pt-BR": "Gerenciar membros. Adicionar e remover membros da organização e alterar seus papéis."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'sso_can_manage',
'Create and change the single sign-on configuration',
6,
'{
"en": "Manage SSO. Create and change the single sign-on configuration of the organization.",
"ru": "Управление SSO. Создавать и изменять настройки единого входа организации.",
"de": "SSO verwalten. Die Single-Sign-on-Konfiguration der Organisation erstellen und ändern.",
"es": "Gestionar SSO. Crear y cambiar la configuración de inicio de sesión único de la organización.",
"pt-BR": "Gerenciar SSO. Criar e alterar a configuração de login único da organização."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'webhooks_can_manage',
'Create, edit and delete project webhooks',
6,
'{
"en": "Manage webhooks. Create, edit and delete webhooks of a project.",
"ru": "Управление вебхуками. Создавать, изменять и удалять вебхуки проекта.",
"de": "Webhooks verwalten. Webhooks eines Projekts erstellen, bearbeiten und löschen.",
"es": "Gestionar webhooks. Crear, editar y eliminar webhooks de un proyecto.",
"pt-BR": "Gerenciar webhooks. Criar, editar e excluir webhooks de um projeto."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -0,0 +1,45 @@
-- Reference list of currencies (ISO 4217) for anything that carries money:
-- invoices, counterparties, seller profiles. Names are stored in English only;
-- the UI localises them through Intl.DisplayNames by code. decimal_digits
-- drives amount formatting (JPY and KRW have none). Rows are never deleted,
-- only deactivated, so existing references stay valid.
CREATE SCHEMA IF NOT EXISTS tv_billing;
CREATE TABLE IF NOT EXISTS tv_billing.currencies (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
code CHAR(3) NOT NULL UNIQUE,
numeric_code SMALLINT NOT NULL UNIQUE,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8) NOT NULL,
decimal_digits SMALLINT NOT NULL DEFAULT 2,
sort_order SMALLINT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT currencies_code_upper_check CHECK (code = UPPER(code)),
CONSTRAINT currencies_decimal_digits_check CHECK (decimal_digits BETWEEN 0 AND 4)
);
CREATE INDEX IF NOT EXISTS idx_currencies_active_sort ON tv_billing.currencies(is_active, sort_order);
INSERT INTO tv_billing.currencies (code, numeric_code, name, symbol, decimal_digits, sort_order) VALUES
('USD', 840, 'US Dollar', '$', 2, 10),
('EUR', 978, 'Euro', '', 2, 20),
('GBP', 826, 'Pound Sterling', '£', 2, 30),
('JPY', 392, 'Japanese Yen', '¥', 0, 40),
('CNY', 156, 'Chinese Yuan', '¥', 2, 50),
('CHF', 756, 'Swiss Franc', 'CHF', 2, 60),
('CAD', 124, 'Canadian Dollar', 'CA$', 2, 70),
('AUD', 36, 'Australian Dollar', 'A$', 2, 80),
('RUB', 643, 'Russian Ruble', '', 2, 25),
('INR', 356, 'Indian Rupee', '', 2, 100),
('BRL', 986, 'Brazilian Real', 'R$', 2, 110),
('KRW', 410, 'South Korean Won', '', 0, 120),
('SGD', 702, 'Singapore Dollar', 'S$', 2, 130),
('HKD', 344, 'Hong Kong Dollar', 'HK$', 2, 140),
('SEK', 752, 'Swedish Krona', 'kr', 2, 150),
('NOK', 578, 'Norwegian Krone', 'kr', 2, 160),
('DKK', 208, 'Danish Krone', 'kr', 2, 170),
('PLN', 985, 'Polish Zloty', '', 2, 180),
('TRY', 949, 'Turkish Lira', '', 2, 190),
('AED', 784, 'UAE Dirham', 'د.إ', 2, 200)
ON CONFLICT (code) DO NOTHING;
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS tv_billing.sellers (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL DEFAULT '',
address VARCHAR(1000) NOT NULL DEFAULT '',
email VARCHAR(320) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
logo_url VARCHAR(1000) NOT NULL DEFAULT '',
currency_code CHAR(3) NOT NULL DEFAULT 'USD' REFERENCES tv_billing.currencies(code),
bank JSONB NOT NULL DEFAULT '{}'::jsonb,
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
default_terms VARCHAR(2000) NOT NULL DEFAULT '',
tax_note VARCHAR(500) NOT NULL DEFAULT '',
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_sellers_org ON tv_billing.sellers(organization_id, archived);
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS tv_billing.counterparties (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL DEFAULT 'organization',
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL DEFAULT '',
address VARCHAR(1000) NOT NULL DEFAULT '',
email VARCHAR(320) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
contact_person VARCHAR(200) NOT NULL DEFAULT '',
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT counterparties_kind_check CHECK (kind IN ('organization', 'person'))
);
CREATE INDEX IF NOT EXISTS idx_billing_counterparties_org ON tv_billing.counterparties(organization_id, archived);
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS tv_billing.invoices (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
goal_id INTEGER REFERENCES tasks.goals(id) ON DELETE SET NULL,
goal_name VARCHAR(500) NOT NULL DEFAULT '',
seller_id INTEGER NOT NULL REFERENCES tv_billing.sellers(id) ON DELETE RESTRICT,
counterparty_id INTEGER NOT NULL REFERENCES tv_billing.counterparties(id) ON DELETE RESTRICT,
number VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'draft',
reference VARCHAR(200) NOT NULL DEFAULT '',
currency_code CHAR(3) NOT NULL REFERENCES tv_billing.currencies(code),
issue_date DATE NOT NULL,
payment_terms VARCHAR(20) NOT NULL DEFAULT 'net14',
due_date DATE,
period_from DATE,
period_to DATE,
discount_type VARCHAR(10) NOT NULL DEFAULT 'percent',
discount_value NUMERIC(12, 2) NOT NULL DEFAULT 0,
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
tax_exempt BOOLEAN NOT NULL DEFAULT FALSE,
tax_note VARCHAR(500) NOT NULL DEFAULT '',
notes VARCHAR(2000) NOT NULL DEFAULT '',
terms VARCHAR(2000) NOT NULL DEFAULT '',
seller_snapshot JSONB NOT NULL,
counterparty_snapshot JSONB NOT NULL,
created_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT invoices_status_check CHECK (status IN ('draft', 'issued', 'paid', 'void')),
CONSTRAINT invoices_discount_type_check CHECK (discount_type IN ('percent', 'amount')),
CONSTRAINT invoices_payment_terms_check CHECK (payment_terms IN ('on_receipt', 'net7', 'net14', 'net30', 'custom')),
CONSTRAINT invoices_number_per_org UNIQUE (organization_id, number)
);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_org_issue ON tv_billing.invoices(organization_id, issue_date DESC);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_counterparty ON tv_billing.invoices(counterparty_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_seller ON tv_billing.invoices(seller_id);
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS tv_billing.invoice_lines (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
invoice_id INTEGER NOT NULL REFERENCES tv_billing.invoices(id) ON DELETE CASCADE,
position SMALLINT NOT NULL DEFAULT 0,
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
description VARCHAR(1000) NOT NULL,
unit VARCHAR(20) NOT NULL DEFAULT 'service',
quantity NUMERIC(12, 2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12, 2) NOT NULL DEFAULT 0,
CONSTRAINT invoice_lines_unit_check CHECK (unit IN ('service', 'hours', 'pcs'))
);
CREATE INDEX IF NOT EXISTS idx_billing_invoice_lines_invoice ON tv_billing.invoice_lines(invoice_id, position);
@@ -0,0 +1,14 @@
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'billing_can_manage',
'Manage invoices, clients and seller companies of the organization',
6,
'{
"en": "Manage billing. Create and edit invoices, clients and seller companies of the organization.",
"ru": "Управление биллингом. Создавать и редактировать инвойсы, клиентов и компании-продавцы организации.",
"de": "Abrechnung verwalten. Rechnungen, Kunden und Firmen der Organisation erstellen und bearbeiten.",
"es": "Gestionar facturación. Crear y editar facturas, clientes y empresas de la organización.",
"pt-BR": "Gerenciar faturamento. Criar e editar faturas, clientes e empresas da organização."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -0,0 +1,11 @@
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS issued_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS voided_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS replaces_invoice_id INTEGER REFERENCES tv_billing.invoices(id) ON DELETE SET NULL;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS template_version SMALLINT NOT NULL DEFAULT 1;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS subtotal NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS discount_amount NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS total NUMERIC(12, 2);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_replaces ON tv_billing.invoices(replaces_invoice_id);
+28
View File
@@ -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}"`);
}
}
}
+10
View File
@@ -6,7 +6,10 @@ 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 OAuthRoutes from '../tv-modules/oauth/OAuthRoutes';
import OAuthWellKnownRoutes from '../tv-modules/oauth/OAuthWellKnownRoutes';
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
@@ -21,6 +24,8 @@ import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
import BillingRoutes from '../tv-modules/billing/BillingRoutes';
import InvoicesRoutes from '../tv-modules/invoices/InvoicesRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -39,7 +44,9 @@ const routes: Record<string, RoutableConstructor> = {
'/module/integrations': IntegrationsRoutes,
'/module/notifications': NotificationsRoutes,
'/module/webhooks': WebhooksRoutes,
'/module/messaging': MessagingRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/oauth': OAuthRoutes,
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
'/module/sso': SsoRoutes,
@@ -48,7 +55,10 @@ const routes: Record<string, RoutableConstructor> = {
'/module/ui-preferences': UiPreferencesRoutes,
'/module/sprints': SprintsRoutes,
'/module/recurrence': RecurrenceRoutes,
'/module/billing': BillingRoutes,
'/module/invoices': InvoicesRoutes,
'/scim/v2': ScimRoutes,
'/.well-known': OAuthWellKnownRoutes,
};
export default routes;
@@ -5,18 +5,12 @@ import { OverdueKpi } from './kpi/OverdueKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
@@ -59,6 +53,12 @@ import { sectionLocales } from './locales'
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
@@ -82,25 +82,25 @@ const builders: SectionBuilder[] = [
new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
new BlockedByDependenciesSection(),
// new BlockedByDependenciesSection(), // disabled
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new OverdueByAgeSection(), // disabled
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
new StatusDistributionSection(),
new ActiveProjectsSection(),
// new ActiveProjectsSection(), // disabled
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
new IncomePerTagMonthSection(),
new ExpensePerTagMonthSection(),
new TopProjectsByAmountSection(),
// new IncomePerTagMonthSection(), // disabled
// new ExpensePerTagMonthSection(), // disabled
// new TopProjectsByAmountSection(), // disabled
]
export class SectionRegistry {
@@ -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
@@ -2,7 +2,6 @@ import type { Request, Response } from 'express';
import { ArkErrors } from 'arktype';
import { getApiTokensManager } from './ApiTokensManager';
import { ApiTokenArkTypeCreate, ApiTokenArkTypeDelete } from './types';
import { Database } from '../../modules/db';
export class ApiTokensController {
private get manager() { return getApiTokensManager(); }
@@ -44,10 +43,7 @@ export class ApiTokensController {
};
fetchPermissions = async (_req: Request, res: Response) => {
const db = Database.getInstance();
const result = await db.query<{ id: number; name: string; description: string; permissionGroup: number }>(
`SELECT id, name, description, permission_group as "permissionGroup" FROM tv_auth.permissions WHERE permission_group <> 1 ORDER BY permission_group, id`
);
return res.tvJson(result?.rows ?? []);
const result = await this.manager.fetchSelectablePermissions();
return res.tvJson(result);
};
}
@@ -1,7 +1,7 @@
import { randomBytes, createHash } from 'crypto';
import { ApiTokensRepository } from './ApiTokensRepository';
import { TOKEN_PREFIX, type ApiTokenArgCreate } from './types';
import type { ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
import type { ApiTokensSchemaTypeForSelect, PermissionsSchemaTypeForSelect } from 'taskview-db-schemas';
export type ApiTokenForClient = Omit<ApiTokensSchemaTypeForSelect, 'tokenHash'>;
@@ -42,6 +42,10 @@ export class ApiTokensManager {
return tokens.map((t) => this.toClient(t));
}
async fetchSelectablePermissions(): Promise<PermissionsSchemaTypeForSelect[]> {
return this.repository.fetchSelectablePermissions();
}
async validateToken(fullToken: string): Promise<ApiTokensSchemaTypeForSelect | null> {
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
const record = await this.repository.findByTokenHash(tokenHash);
@@ -1,5 +1,10 @@
import { and, eq } from 'drizzle-orm';
import { ApiTokensSchema, type ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
import {
ApiTokensSchema,
PermissionsSchema,
type ApiTokensSchemaTypeForSelect,
type PermissionsSchemaTypeForSelect,
} from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
@@ -20,15 +25,21 @@ export class ApiTokensRepository {
async delete(id: number, userId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(ApiTokensSchema).where(
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId))
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId), isNull(ApiTokensSchema.grantId))
)
);
return !!result?.rowCount;
}
/**
* Only manually issued tokens. OAuth access tokens live in the same table but
* belong to a grant - they are listed and revoked as connected apps instead.
*/
async fetchByUserId(userId: number): Promise<ApiTokensSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.userId, userId))
this.db.dbDrizzle.select().from(ApiTokensSchema).where(
and(eq(ApiTokensSchema.userId, userId), isNull(ApiTokensSchema.grantId))
)
);
return result ?? [];
}
@@ -40,6 +51,21 @@ export class ApiTokensRepository {
return result?.[0] ?? null;
}
/**
* Permissions offered when scoping a token. Group 1 is excluded: those keys
* exist in the table but are enforced nowhere in the code, so offering them
* would promise a restriction that never happens.
*/
async fetchSelectablePermissions(): Promise<PermissionsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select()
.from(PermissionsSchema)
.where(ne(PermissionsSchema.permissionGroup, 1))
.orderBy(asc(PermissionsSchema.permissionGroup), asc(PermissionsSchema.id))
);
return result ?? [];
}
async updateLastUsedAt(id: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(ApiTokensSchema)
+225 -1
View File
@@ -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);
@@ -345,6 +367,16 @@ export default class AuthController {
// Invalidate code immediately to prevent replay attacks
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
if (userData.block) {
if (!userData.confirm_email_code) {
return res.status(403).send({ message: 'account_blocked' });
}
const confirmed = await req.appUser.authManager.repository.markEmailConfirmed(userData.email);
if (!confirmed) {
return res.status(500).end();
}
}
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
@@ -418,6 +450,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 +690,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;
+103 -1
View File
@@ -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 {
@@ -95,6 +119,21 @@ export default class AuthModel {
}
}
async markEmailConfirmed(email: string): Promise<boolean> {
if (!email) return false;
try {
const result = await this.db.dbDrizzle
.update(UsersSchema)
.set({ confirmEmailCode: null, block: 0 })
.where(eq(UsersSchema.email, email));
return (result.rowCount ?? 0) > 0;
} catch (error) {
$logger.error(error, `Error marking email confirmed for ${email}`);
return false;
}
}
async confirmEmail(login: string, code: string, block: number): Promise<boolean> {
const query = `UPDATE tv_auth.users
SET confirm_email_code = NULL, block = $1
@@ -133,6 +172,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';
+17 -5
View File
@@ -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),
+80
View File
@@ -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 };
}
}
@@ -512,4 +512,65 @@ describe('Login API', () => {
expect(te).toBe(0);
});
it('loginByCode confirms and admits a blocked-unconfirmed account', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const userModel = new AuthModel();
const before = await userModel.getUserByLogin(email, true);
expect(before).toBeTruthy();
expect((before as any).block).toBe(1);
expect((before as any).confirm_email_code).toBeTruthy();
const code = '654321';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
const response = await axios.post(`${url}/module/auth/login-by-code`, { email, code });
expect(response.status).toBe(200);
expect(response.data.access).toBeTruthy();
expect(response.data.refresh).toBeTruthy();
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(0);
expect((after as any).confirm_email_code).toBeNull();
});
it('loginByCode rejects a banned account (blocked, no confirm code)', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const db = Database.getInstance();
await db.query('update tv_auth.users set block = 1, confirm_email_code = null where email = $1', [email]);
const userModel = new AuthModel();
const code = '112233';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
let status = 0;
let message = '';
await axios.post(`${url}/module/auth/login-by-code`, { email, code }).catch((err) => {
status = err.response.status;
message = err.response.data.message;
});
expect(status).toBe(403);
expect(message).toBe('account_blocked');
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(1);
});
});
@@ -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 = {
@@ -0,0 +1,107 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { BillingManager } from './BillingManager'
import {
BillingArkTypeArchive,
BillingArkTypeId,
BillingArkTypeList,
CounterpartyArkTypeCreate,
CounterpartyArkTypeUpdate,
SellerArkTypeCreate,
SellerArkTypeUpdate,
type DeleteResult,
} from './types'
const DELETE_STATUS: Record<DeleteResult, number> = { deleted: 200, in_use: 409, not_found: 404 }
export class BillingController {
private readonly manager = new BillingManager()
currencies = async (_req: Request, res: Response) => {
return res.tvJson(await this.manager.fetchCurrencies())
}
fetchSellers = async (req: Request, res: Response) => {
const data = BillingArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchSellers(data))
}
createSeller = async (req: Request, res: Response) => {
const data = SellerArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.createSeller(data)
if (!result) return res.status(500).end()
return res.tvJson(result)
}
updateSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = SellerArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.updateSeller({ sellerId: id.id, data })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
archiveSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = BillingArkTypeArchive(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.setSellerArchived({ id: id.id, archived: data.archived })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
deleteSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.deleteSeller(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
fetchCounterparties = async (req: Request, res: Response) => {
const data = BillingArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchCounterparties(data))
}
createCounterparty = async (req: Request, res: Response) => {
const data = CounterpartyArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.createCounterparty(data)
if (!result) return res.status(500).end()
return res.tvJson(result)
}
updateCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = CounterpartyArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.updateCounterparty({ counterpartyId: id.id, data })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
archiveCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = BillingArkTypeArchive(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.setCounterpartyArchived({ id: id.id, archived: data.archived })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
deleteCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.deleteCounterparty(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
}
@@ -0,0 +1,74 @@
import { BillingRepository } from './BillingRepository'
import type {
BillingArgList,
CounterpartyArgCreate,
CounterpartyForClient,
CounterpartyUpdateArgs,
DeleteResult,
SellerArgCreate,
SellerForClient,
SellerUpdateArgs,
SetArchivedArgs,
} from './types'
export class BillingManager {
public readonly repository: BillingRepository
constructor() {
this.repository = new BillingRepository()
}
fetchCurrencies() {
return this.repository.fetchCurrencies()
}
fetchSellers(args: BillingArgList): Promise<SellerForClient[]> {
return this.repository.fetchSellers(args)
}
fetchSellerById(sellerId: number): Promise<SellerForClient | null> {
return this.repository.fetchSellerById(sellerId)
}
createSeller(data: SellerArgCreate): Promise<SellerForClient | null> {
return this.repository.createSeller(data)
}
updateSeller(args: SellerUpdateArgs): Promise<SellerForClient | null> {
return this.repository.updateSeller(args)
}
setSellerArchived(args: SetArchivedArgs): Promise<SellerForClient | null> {
return this.repository.setSellerArchived(args)
}
async deleteSeller(sellerId: number): Promise<DeleteResult> {
if ((await this.repository.countInvoicesBySeller(sellerId)) > 0) return 'in_use'
return (await this.repository.deleteSeller(sellerId)) ? 'deleted' : 'not_found'
}
fetchCounterparties(args: BillingArgList): Promise<CounterpartyForClient[]> {
return this.repository.fetchCounterparties(args)
}
fetchCounterpartyById(counterpartyId: number): Promise<CounterpartyForClient | null> {
return this.repository.fetchCounterpartyById(counterpartyId)
}
createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartyForClient | null> {
return this.repository.createCounterparty(data)
}
updateCounterparty(args: CounterpartyUpdateArgs): Promise<CounterpartyForClient | null> {
return this.repository.updateCounterparty(args)
}
setCounterpartyArchived(args: SetArchivedArgs): Promise<CounterpartyForClient | null> {
return this.repository.setCounterpartyArchived(args)
}
async deleteCounterparty(counterpartyId: number): Promise<DeleteResult> {
if ((await this.repository.countInvoicesByCounterparty(counterpartyId)) > 0) return 'in_use'
return (await this.repository.deleteCounterparty(counterpartyId)) ? 'deleted' : 'not_found'
}
}
@@ -0,0 +1,199 @@
import { and, asc, count, eq } from 'drizzle-orm'
import {
CounterpartiesSchema,
CurrenciesSchema,
InvoicesSchema,
SellersSchema,
type CounterpartiesSchemaTypeForSelect,
type CurrenciesSchemaTypeForSelect,
type SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
BillingArgList,
CounterpartyArgCreate,
CounterpartyArgUpdate,
CounterpartyUpdateArgs,
SellerArgCreate,
SellerArgUpdate,
SellerUpdateArgs,
SetArchivedArgs,
} from './types'
export class BillingRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async fetchCurrencies(): Promise<CurrenciesSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(CurrenciesSchema)
.where(eq(CurrenciesSchema.isActive, true))
.orderBy(asc(CurrenciesSchema.sortOrder)),
)
return result ?? []
}
async fetchSellers({ organizationId, includeArchived }: BillingArgList): Promise<SellersSchemaTypeForSelect[]> {
const conditions = [eq(SellersSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(SellersSchema.archived, false))
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SellersSchema).where(and(...conditions)).orderBy(asc(SellersSchema.name)),
)
return result ?? []
}
async fetchSellerById(sellerId: number): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SellersSchema).where(eq(SellersSchema.id, sellerId)),
)
return result?.[0] ?? null
}
private sellerColumns(data: SellerArgUpdate) {
return {
name: data.name,
legalName: data.legalName,
address: data.address,
email: data.email,
phone: data.phone,
logoUrl: data.logoUrl,
currencyCode: data.currencyCode,
bank: data.bank,
requisites: data.requisites,
defaultTerms: data.defaultTerms,
taxNote: data.taxNote,
}
}
private counterpartyColumns(data: CounterpartyArgUpdate) {
return {
kind: data.kind,
name: data.name,
legalName: data.legalName,
address: data.address,
email: data.email,
phone: data.phone,
contactPerson: data.contactPerson,
requisites: data.requisites,
}
}
async createSeller(data: SellerArgCreate): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SellersSchema)
.values({ organizationId: data.organizationId, ...this.sellerColumns(data) })
.returning(),
)
return result?.[0] ?? null
}
async updateSeller({ sellerId, data }: SellerUpdateArgs): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SellersSchema)
.set({ ...this.sellerColumns(data), updatedAt: new Date() })
.where(eq(SellersSchema.id, sellerId))
.returning(),
)
return result?.[0] ?? null
}
async setSellerArchived({ id, archived }: SetArchivedArgs): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SellersSchema)
.set({ archived, updatedAt: new Date() })
.where(eq(SellersSchema.id, id))
.returning(),
)
return result?.[0] ?? null
}
async deleteSeller(sellerId: number): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle.delete(SellersSchema).where(eq(SellersSchema.id, sellerId)))
return !!result?.rowCount
}
async countInvoicesBySeller(sellerId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ total: count() }).from(InvoicesSchema).where(eq(InvoicesSchema.sellerId, sellerId)),
)
return result?.[0]?.total ?? 0
}
async fetchCounterparties({ organizationId, includeArchived }: BillingArgList): Promise<CounterpartiesSchemaTypeForSelect[]> {
const conditions = [eq(CounterpartiesSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(CounterpartiesSchema)
.where(and(...conditions))
.orderBy(asc(CounterpartiesSchema.name)),
)
return result ?? []
}
async fetchCounterpartyById(counterpartyId: number): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
)
return result?.[0] ?? null
}
async createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(CounterpartiesSchema)
.values({ organizationId: data.organizationId, ...this.counterpartyColumns(data) })
.returning(),
)
return result?.[0] ?? null
}
async updateCounterparty({ counterpartyId, data }: CounterpartyUpdateArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(CounterpartiesSchema)
.set({ ...this.counterpartyColumns(data), updatedAt: new Date() })
.where(eq(CounterpartiesSchema.id, counterpartyId))
.returning(),
)
return result?.[0] ?? null
}
async setCounterpartyArchived({ id, archived }: SetArchivedArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(CounterpartiesSchema)
.set({ archived, updatedAt: new Date() })
.where(eq(CounterpartiesSchema.id, id))
.returning(),
)
return result?.[0] ?? null
}
async deleteCounterparty(counterpartyId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
)
return !!result?.rowCount
}
async countInvoicesByCounterparty(counterpartyId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ total: count() })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.counterpartyId, counterpartyId)),
)
return result?.[0]?.total ?? 0
}
}
@@ -0,0 +1,41 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
import { BillingController } from './BillingController'
import { isOrgAdminForCounterparty, isOrgAdminForSeller } from './middlewares/is-org-admin-for-billing'
export default class BillingRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: BillingController
constructor() {
this.router = Router()
this.controller = new BillingController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
this.router.get('/currencies', [IsLoggedIn], this.controller.currencies)
this.router.get('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchSellers)
this.router.post('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createSeller)
this.router.patch('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.updateSeller)
this.router.patch('/sellers/:id/archive', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.archiveSeller)
this.router.delete('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.deleteSeller)
this.router.get('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchCounterparties)
this.router.post('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createCounterparty)
this.router.patch('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.updateCounterparty)
this.router.patch('/counterparties/:id/archive', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.archiveCounterparty)
this.router.delete('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.deleteCounterparty)
}
}
@@ -0,0 +1,10 @@
import { BillingRepository } from '../BillingRepository'
import { isOrgAdminFor } from './is-org-admin-for'
const repository = new BillingRepository()
export const isOrgAdminForSeller = isOrgAdminFor(async (id) => (await repository.fetchSellerById(id))?.organizationId ?? null)
export const isOrgAdminForCounterparty = isOrgAdminFor(
async (id) => (await repository.fetchCounterpartyById(id))?.organizationId ?? null,
)
@@ -0,0 +1,20 @@
import type { NextFunction, Request, Response } from 'express'
import { ORG_ADMIN_ROLES, type OrgRole } from '../../organizations/types'
import { parsePositiveInt } from '../../../utils/helpers'
export type OrganizationIdResolver = (id: number) => Promise<number | null>
export const isOrgAdminFor = (resolveOrganizationId: OrganizationIdResolver) => {
return async (req: Request, res: Response, next: NextFunction) => {
const id = parsePositiveInt(req.params.id)
if (id === null) return res.status(400).end()
const organizationId = await resolveOrganizationId(id)
if (organizationId === null) return res.status(404).end()
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) return res.status(403).end()
return next()
}
}
+103
View File
@@ -0,0 +1,103 @@
import { type } from 'arktype'
import type {
BillingBankDetails,
BillingRequisite,
CounterpartiesSchemaTypeForSelect,
SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
const NumberFromString = type('string|number').pipe((v) => Number(v))
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
export const RequisiteArkType = type({
key: 'string<=64',
label: 'string<=100',
value: 'string<=300',
})
export const BankDetailsArkType = type({
bankName: 'string<=200',
accountNumber: 'string<=64',
iban: 'string<=64',
swift: 'string<=32',
correspondentAccount: 'string<=64',
})
export const SellerArkTypeCreate = type({
organizationId: 'number',
name: '1<=string<=200',
legalName: 'string<=300',
address: 'string<=1000',
email: 'string<=320',
phone: 'string<=50',
logoUrl: 'string<=1000',
currencyCode: /^[A-Z]{3}$/,
bank: BankDetailsArkType,
requisites: RequisiteArkType.array(),
defaultTerms: 'string<=2000',
taxNote: 'string<=500',
})
export type SellerArgCreate = typeof SellerArkTypeCreate.infer
export const SellerArkTypeUpdate = SellerArkTypeCreate.omit('organizationId')
export type SellerArgUpdate = typeof SellerArkTypeUpdate.infer
export const CounterpartyArkTypeCreate = type({
organizationId: 'number',
kind: "'organization' | 'person'",
name: '1<=string<=200',
legalName: 'string<=300',
address: 'string<=1000',
email: 'string<=320',
phone: 'string<=50',
contactPerson: 'string<=200',
requisites: RequisiteArkType.array(),
})
export type CounterpartyArgCreate = typeof CounterpartyArkTypeCreate.infer
export const CounterpartyArkTypeUpdate = CounterpartyArkTypeCreate.omit('organizationId')
export type CounterpartyArgUpdate = typeof CounterpartyArkTypeUpdate.infer
export const BillingArkTypeList = type({
organizationId: NumberFromString,
'includeArchived?': BooleanFromString,
})
export type BillingArgList = typeof BillingArkTypeList.infer
export const BillingArkTypeId = type({
id: NumberFromString,
})
export const BillingArkTypeArchive = type({
archived: 'boolean',
})
export type SellerUpdateArgs = {
sellerId: number
data: SellerArgUpdate
}
export type CounterpartyUpdateArgs = {
counterpartyId: number
data: CounterpartyArgUpdate
}
export type SetArchivedArgs = {
id: number
archived: boolean
}
export type SellerForClient = Omit<SellersSchemaTypeForSelect, 'bank' | 'requisites'> & {
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type CounterpartyForClient = Omit<CounterpartiesSchemaTypeForSelect, 'requisites'> & {
requisites: BillingRequisite[]
}
export type DeleteResult = 'deleted' | 'in_use' | 'not_found'
@@ -1,8 +1,12 @@
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;
// the only route using this guard names the goal in the path
const goalId = req.params.goalId;
if (!goalId) {
return res.status(400).end();
@@ -19,5 +23,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))
);
@@ -5,7 +5,7 @@ import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
import { CollaborationController } from './CollaborationController';
import { CanAddUserCollaboration } from './middlewares/CanAddUserCollaboration';
import { CanDeleteUserCollaboration } from './middlewares/CanDeleteUserCollaboration';
// import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanToggleRolesCollaboration } from './middlewares/CanToggleRolesCollaboration';
export default class CollaborationRoutes implements Routable {
@@ -56,6 +56,10 @@ export default class CollaborationRoutes implements Routable {
/**
* Fetch users for goal for collaboration
*/
this.router.get('/:goalId', [IsLoggedIn], this.collaborationController.fetchUsersForGoalNew);
this.router.get(
'/:goalId',
[IsLoggedIn, CanFetchUsersCollaboration],
this.collaborationController.fetchUsersForGoalNew
);
}
}
@@ -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('&lt;img src=x onerror=alert(1)&gt;');
expect(html).toContain('Bob &amp; &quot;Co&quot;');
});
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>`
@@ -21,7 +21,8 @@ export const CanFetchUsersCollaboration = async (req: Request, res: Response, ne
if (
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS) ||
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)
) {
return next();
}
+21 -1
View File
@@ -63,8 +63,16 @@ export default class GoalsManager {
return new GoalItemForClient(goal, (await this.getPermissionsForGoal(goal.id)).getAllPermissions());
}
private async isInboxGoal(goalId: number): Promise<boolean> {
const goal = await this.goalsRepository.findGoalById(goalId);
return !!goal && goal.isInbox;
}
/** @deprecated use deleteGoalNew instead */
async deleteGoal(goalId: number): Promise<boolean> {
if (await this.isInboxGoal(goalId)) {
return false;
}
return await this.goalsRepository.deleteGoal(goalId);
}
@@ -88,6 +96,9 @@ export default class GoalsManager {
}
async updateArchive(goalId: number, archive: GoalItemInDb['archive']) {
if (archive === 1 && (await this.isInboxGoal(goalId))) {
return false;
}
return await this.goalsRepository.updateArchive(goalId, archive);
}
@@ -134,6 +145,12 @@ export default class GoalsManager {
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsItemForClientWithPermissions | false> {
// The Inbox must never be archived. Archiving flows through this endpoint
// (PATCH /module/goals), not the unrouted updateArchive, so the guard lives here.
if (goalData.archive === 1 && (await this.isInboxGoal(goalData.id))) {
return false;
}
const goal = await this.goalsRepository.updateGoalNew(goalData);
if (!goal) {
@@ -153,6 +170,9 @@ export default class GoalsManager {
}
async deleteGoalNew(goalData: GoalsArgDelete) {
if (await this.isInboxGoal(goalData.goalId)) {
return false;
}
return await this.goalsRepository.deleteGoalNew(goalData);
}
@@ -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],
})
}
+51 -3
View File
@@ -12,7 +12,7 @@ import type { AddGoalToDbArg, GoalItemInDb, GoalItemsInDb, UpdateGoalDbArg } fro
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import { callWithCatch } from '../../utils/helpers';
import type { GoalsArgAdd, GoalsArgDelete, GoalsArgUpdate } from './types';
import type { GoalsArgAdd, GoalsArgCreateInbox, GoalsArgDelete, GoalsArgUpdate } from './types';
export class GoalsRepository {
private readonly db: Database;
@@ -157,6 +157,7 @@ export class GoalsRepository {
backlogVersion: GoalsSchema.backlogVersion,
organizationId: GoalsSchema.organizationId,
estimateUnit: GoalsSchema.estimateUnit,
isInbox: GoalsSchema.isInbox,
})
.from(GoalsSchema)
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
@@ -232,9 +233,56 @@ export class GoalsRepository {
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
async findInboxGoal(organizationId: number): Promise<GoalsSchemaTypeForSelect | false> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(goalData).where(eq(GoalsSchema.id, goalData.id)).returning()
this.db.dbDrizzle
.select()
.from(GoalsSchema)
.where(and(eq(GoalsSchema.organizationId, organizationId), eq(GoalsSchema.isInbox, true)))
);
if (!result || result.length === 0) return false;
return result[0];
}
async createInboxGoal(args: GoalsArgCreateInbox): Promise<GoalsSchemaTypeForSelect | false> {
const existing = await this.findInboxGoal(args.organizationId);
if (existing) {
return existing;
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(GoalsSchema)
.values({
name: 'Inbox',
owner: args.ownerId,
organizationId: args.organizationId,
isInbox: true,
})
.returning()
);
if (!result) {
return false;
}
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
const updates: Partial<typeof GoalsSchema.$inferInsert> = {};
if (goalData.name !== undefined) updates.name = goalData.name;
if (goalData.description !== undefined) updates.description = goalData.description;
if (goalData.color !== undefined) updates.color = goalData.color;
if (goalData.estimateUnit !== undefined) updates.estimateUnit = goalData.estimateUnit;
if (goalData.archive !== undefined) updates.archive = goalData.archive;
if (Object.keys(updates).length === 0) {
return this.findGoalById(goalData.id);
}
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(updates).where(eq(GoalsSchema.id, goalData.id)).returning()
);
if (!result) {
return false;
+6
View File
@@ -17,6 +17,7 @@ export const GoalsArkTypeUpdate = type({
'description?': 'string | null',
'color?': 'string | null',
"estimateUnit?": "'hours' | 'points'",
'archive?': '0 | 1',
});
export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer;
@@ -34,3 +35,8 @@ export const GoalsArkTypeFetch = type({
export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer;
export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient };
export type GoalsArgCreateInbox = {
ownerId: number;
organizationId: number;
};
@@ -33,6 +33,15 @@ export class GraphController {
return res.tvJson(edges);
};
fetchTaskEdges = async (req: Request, res: Response) => {
const taskId = Number(req.params.taskId);
if (!Number.isFinite(taskId)) {
return res.status(400).send('Task ID is required');
}
const edges = await req.appUser.graphManager.fetchEdgesForTask(taskId);
return res.tvJson(edges);
};
deleteEdge = async (req: Request, res: Response) => {
if (!req.params.id) {
return res.status(400).send('Edge ID is required');
+4
View File
@@ -19,6 +19,10 @@ export class GraphManager {
return await this.repository.fetchAllEdges(goalId);
}
async fetchEdgesForTask(taskId: number) {
return await this.repository.fetchEdgesForTask(taskId);
}
async deleteEdge(id: number) {
return await this.repository.deleteEdge(id);
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, or } from 'drizzle-orm';
import { GraphRelationsSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
@@ -32,6 +32,16 @@ export class GraphRepository {
return result ?? [];
}
public async fetchEdgesForTask(taskId: number): Promise<GraphReturnRelationsType[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(GraphRelationsSchema)
.where(or(eq(GraphRelationsSchema.fromTaskId, taskId), eq(GraphRelationsSchema.toTaskId, taskId)))
);
return result ?? [];
}
public async deleteEdge(id: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
+1
View File
@@ -21,6 +21,7 @@ export default class GraphRoutes implements Routable {
initRoutes() {
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
this.router.get('/task/:taskId', [IsLoggedIn, CanViewGraph], this.graphController.fetchTaskEdges);
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
}
@@ -3,28 +3,32 @@ import { GraphRepository } from '../GraphRepository';
import { TasksRepository } from '../../tasks/TasksRepository';
/**
* Resolves goalId from graph request.
* - GET /:goalId → params.goalId
* - POST (addEdge) → resolve via fromTaskId (body.source)
* - DELETE /:id → resolve via edge id
* Resolves the single goal a graph request belongs to.
*
* The source is chosen by what the route actually carries, not by probing every
* field in turn: a route parameter always wins, and only a request with no
* parameters at all (addEdge) is resolved from the body. Reading the body first
* would let a caller point the guard at a task they own while the handler acts
* on someone else's edge.
*
* A graph lives inside one project, so an edge whose endpoints sit in different
* goals is not a permission question — it is an impossible object. It resolves
* to null and the guards reject it before any permission is considered, the same
* invariant the tasks.check_task_graph_relation_goal trigger enforces in the DB.
*/
export async function resolveGoalId(req: Request): Promise<number | null> {
// Direct goalId in params (fetchAllEdges)
// fetchAllEdges: GET /:goalId
if (req.params.goalId) {
const id = Number(req.params.goalId);
return isNaN(id) ? null : id;
const goalId = Number(req.params.goalId);
return isNaN(goalId) ? null : goalId;
}
// addEdge: resolve goalId from task
if (req.body?.source) {
const taskId = Number(req.body.source);
if (isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
// fetchTaskEdges: GET /task/:taskId
if (req.params.taskId) {
return goalIdForTask(req.params.taskId);
}
// deleteEdge: resolve goalId from edge
// deleteEdge: DELETE /:id
if (req.params.id) {
const edgeId = Number(req.params.id);
if (isNaN(edgeId)) return null;
@@ -33,5 +37,25 @@ export async function resolveGoalId(req: Request): Promise<number | null> {
return edge?.goalId ?? null;
}
// addEdge: POST with { source, target } — both endpoints must be in one goal
if (req.body?.source) {
const sourceGoalId = await goalIdForTask(req.body.source);
if (sourceGoalId === null) return null;
const targetGoalId = await goalIdForTask(req.body.target);
if (targetGoalId !== sourceGoalId) return null;
return sourceGoalId;
}
return null;
}
async function goalIdForTask(rawTaskId: unknown): Promise<number | null> {
const taskId = Number(rawTaskId);
if (!taskId || isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
}
@@ -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
}
}
@@ -2,22 +2,26 @@ import type { Request } from 'express';
import { IntegrationsRepository } from '../IntegrationsRepository';
/**
* Resolves projectId from request.
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
* Resolves the project to authorize the request against.
*
* When the request names an integration, the project is derived from that
* integration and a projectId supplied by the caller is ignored: every handler
* that takes an integration id acts on the integration, so authorizing a
* caller-supplied project would guard a different object than the one touched.
*
* Only create and fetch carry no integration id — there the project itself is
* the object being acted on, so it is read from the request.
*/
export async function resolveProjectId(req: Request): Promise<number | null> {
// Direct projectId in body or query
const directId = req.body?.projectId ?? req.query?.projectId;
if (directId) {
const id = Number(directId);
return isNaN(id) ? null : id;
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (integrationId && !isNaN(integrationId)) {
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
}
// integrationId from body or query, or id from body
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (!integrationId || isNaN(integrationId)) return null;
const projectId = Number(req.body?.projectId || req.query?.projectId);
if (!projectId || isNaN(projectId)) return null;
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
return projectId;
}
@@ -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}`,
},
},
);
}
+35 -2
View File
@@ -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,233 @@
import pdfmakeModule from 'pdfmake'
import vfs from 'pdfmake/build/vfs_fonts.js'
import { invoiceLineAmount } from '../../utils/invoiceTotals'
import type { InvoiceForClient, InvoicePdfLang, PdfmakeServer } from './types'
const LABELS: Record<InvoicePdfLang, Record<string, string>> = {
en: {
title: 'INVOICE',
void: 'VOID',
issueDate: 'Issue date',
dueDate: 'Due date',
reference: 'Reference',
period: 'Period',
replaces: 'Replaces',
billTo: 'Bill to',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
price: 'Price',
amount: 'Amount',
subtotal: 'Subtotal',
discount: 'Discount',
tax: 'Tax',
total: 'Total',
paymentDetails: 'Payment details',
bankName: 'Bank',
accountNumber: 'Account',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Correspondent account',
service: 'service',
hours: 'h',
pcs: 'pcs',
},
ru: {
title: 'СЧЁТ',
void: 'АННУЛИРОВАН',
issueDate: 'Дата выставления',
dueDate: 'Срок оплаты',
reference: 'Основание',
period: 'Период',
replaces: 'Взамен',
billTo: 'Плательщик',
description: 'Описание',
qty: 'Кол-во',
unit: 'Ед.',
price: 'Цена',
amount: 'Сумма',
subtotal: 'Промежуточная сумма',
discount: 'Скидка',
tax: 'Налог',
total: 'Итого',
paymentDetails: 'Реквизиты для оплаты',
bankName: 'Банк',
accountNumber: 'Расчётный счёт',
iban: 'IBAN',
swift: 'SWIFT / БИК',
correspondentAccount: 'Корр. счёт',
service: 'усл.',
hours: 'ч',
pcs: 'шт.',
},
}
const FONT_FILES = ['Roboto-Regular.ttf', 'Roboto-Medium.ttf', 'Roboto-Italic.ttf', 'Roboto-MediumItalic.ttf']
const MUTED = '#6b7280'
const RULE = '#d4d4d8'
export class InvoicePdfRenderer {
private static fontsReady = false
private readonly pdfmake = pdfmakeModule as unknown as PdfmakeServer
constructor() {
if (!InvoicePdfRenderer.fontsReady) {
for (const name of FONT_FILES) this.pdfmake.virtualfs.writeFileSync(name, Buffer.from(vfs[name], 'base64'))
this.pdfmake.setFonts({
Roboto: {
normal: 'Roboto-Regular.ttf',
bold: 'Roboto-Medium.ttf',
italics: 'Roboto-Italic.ttf',
bolditalics: 'Roboto-MediumItalic.ttf',
},
})
InvoicePdfRenderer.fontsReady = true
}
}
render(invoice: InvoiceForClient, lang: InvoicePdfLang): Promise<Buffer> {
return this.pdfmake.createPdf(this.buildDocument(invoice, lang)).getBuffer()
}
private buildDocument(invoice: InvoiceForClient, lang: InvoicePdfLang) {
const t = LABELS[lang]
const money = (amount: number) => this.formatMoney(amount, invoice.currencyCode, lang)
const date = (value: string | null) => this.formatDate(value, lang)
const metaRows: [string, string][] = [[t.issueDate, date(invoice.issueDate)]]
if (invoice.dueDate) metaRows.push([t.dueDate, date(invoice.dueDate)])
if (invoice.reference) metaRows.push([t.reference, invoice.reference])
if (invoice.periodFrom || invoice.periodTo) metaRows.push([t.period, `${date(invoice.periodFrom)} ${date(invoice.periodTo)}`])
const lineRows = invoice.lines.map((line) => [
{ text: line.description },
{ text: String(line.quantity), alignment: 'right' },
{ text: t[line.unit] ?? line.unit, color: MUTED },
{ text: money(line.unitPrice), alignment: 'right' },
{ text: money(invoiceLineAmount(line.quantity, line.unitPrice)), alignment: 'right' },
])
const totalsRows: unknown[] = [[{ text: t.subtotal, color: MUTED }, { text: money(invoice.totals.subtotal), alignment: 'right' }]]
if (invoice.totals.discount > 0) totalsRows.push([{ text: t.discount, color: MUTED }, { text: `${money(invoice.totals.discount)}`, alignment: 'right' }])
if (!invoice.taxExempt) totalsRows.push([{ text: `${t.tax} ${invoice.taxRate}%`, color: MUTED }, { text: money(invoice.totals.tax), alignment: 'right' }])
totalsRows.push([{ text: t.total, bold: true, fontSize: 12 }, { text: money(invoice.totals.total), bold: true, fontSize: 12, alignment: 'right' }])
const bank = invoice.seller.bank
const bankRows = (
[
[t.bankName, bank.bankName],
[t.accountNumber, bank.accountNumber],
[t.iban, bank.iban],
[t.swift, bank.swift],
[t.correspondentAccount, bank.correspondentAccount],
] as [string, string][]
).filter(([, value]) => value.trim().length > 0)
const content: unknown[] = [
{
columns: [
{
width: '*',
stack: [
{ text: invoice.seller.legalName || invoice.seller.name, fontSize: 14, bold: true },
...this.partyLines(invoice.seller),
],
},
{
width: 'auto',
stack: [
{ text: t.title, fontSize: 20, bold: true, alignment: 'right' },
{ text: invoice.number, alignment: 'right', margin: [0, 2, 0, 8] },
{
table: { body: metaRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value, alignment: 'right' }]) },
layout: 'noBorders',
fontSize: 9,
},
],
},
],
columnGap: 24,
},
{ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: RULE }], margin: [0, 12, 0, 12] },
{ text: t.billTo.toUpperCase(), fontSize: 8, bold: true, color: MUTED },
{ text: invoice.counterparty.legalName || invoice.counterparty.name, bold: true, margin: [0, 2, 0, 0] },
...(invoice.counterparty.contactPerson ? [{ text: invoice.counterparty.contactPerson }] : []),
...this.partyLines(invoice.counterparty),
{
table: {
headerRows: 1,
widths: ['*', 40, 44, 80, 90],
body: [
[
{ text: t.description.toUpperCase(), style: 'th' },
{ text: t.qty.toUpperCase(), style: 'th', alignment: 'right' },
{ text: t.unit.toUpperCase(), style: 'th' },
{ text: t.price.toUpperCase(), style: 'th', alignment: 'right' },
{ text: t.amount.toUpperCase(), style: 'th', alignment: 'right' },
],
...lineRows,
],
},
layout: {
hLineWidth: (index: number, node: { table: { body: unknown[] } }) => (index === 0 || index === node.table.body.length ? 0 : 0.5),
vLineWidth: () => 0,
hLineColor: () => RULE,
paddingTop: () => 6,
paddingBottom: () => 6,
},
margin: [0, 18, 0, 8],
},
{
columns: [
{ width: '*', text: '' },
{ width: 220, table: { widths: ['*', 'auto'], body: totalsRows }, layout: 'noBorders' },
],
},
]
if (bankRows.length > 0) {
content.push(
{ text: t.paymentDetails.toUpperCase(), fontSize: 8, bold: true, color: MUTED, margin: [0, 20, 0, 4] },
{ table: { body: bankRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value }]) }, layout: 'noBorders' },
)
}
if (invoice.taxExempt && invoice.taxNote) content.push({ text: invoice.taxNote, margin: [0, 16, 0, 0], color: '#374151' })
if (invoice.terms) content.push({ text: invoice.terms, margin: [0, 8, 0, 0], color: '#374151' })
if (invoice.notes) content.push({ text: invoice.notes, margin: [0, 8, 0, 0], color: '#374151' })
return {
pageSize: 'A4',
pageMargins: [40, 40, 40, 40],
info: { title: invoice.number },
...(invoice.status === 'void' ? { watermark: { text: t.void, color: '#ef4444', opacity: 0.12, bold: true } } : {}),
defaultStyle: { font: 'Roboto', fontSize: 10, color: '#18181b' },
styles: { th: { fontSize: 8, bold: true, color: MUTED } },
content,
}
}
private partyLines(party: InvoiceForClient['seller'] | InvoiceForClient['counterparty']) {
const lines: unknown[] = []
if (party.address) lines.push({ text: party.address, color: MUTED, fontSize: 9 })
for (const item of party.requisites) {
if (item.label.trim() && item.value.trim()) lines.push({ text: `${item.label}: ${item.value}`, color: MUTED, fontSize: 9 })
}
const contacts = [party.email, party.phone].filter(Boolean).join(' · ')
if (contacts) lines.push({ text: contacts, color: MUTED, fontSize: 9 })
return lines
}
private formatMoney(amount: number, currencyCode: string, lang: InvoicePdfLang): string {
try {
return new Intl.NumberFormat(lang === 'ru' ? 'ru-RU' : 'en-US', { style: 'currency', currency: currencyCode }).format(amount)
} catch {
return `${amount.toFixed(2)} ${currencyCode}`
}
}
private formatDate(value: string | null, lang: InvoicePdfLang): string {
if (!value) return ''
const [year, month, day] = value.split('-')
return lang === 'ru' ? `${day}.${month}.${year}` : `${day}.${month}.${year}`
}
}
@@ -0,0 +1,103 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { InvoicesManager } from './InvoicesManager'
import {
InvoiceArkTypeCreate,
InvoiceArkTypeId,
InvoiceArkTypeList,
InvoiceArkTypePdf,
InvoiceArkTypeStatus,
InvoiceArkTypeUpdate,
type InvoiceDeleteResult,
type InvoiceTransitionResult,
type InvoiceWriteError,
} from './types'
const WRITE_ERROR_STATUS: Record<InvoiceWriteError, number> = {
seller_not_found: 422,
counterparty_not_found: 422,
goal_not_found: 422,
duplicate_number: 409,
not_found: 404,
not_draft: 409,
}
const TRANSITION_ERROR_STATUS = { not_found: 404, invalid_transition: 409, missing_requisites: 422 } as const
const DELETE_STATUS: Record<InvoiceDeleteResult, number> = { deleted: 200, not_draft: 409, not_found: 404 }
export class InvoicesController {
private readonly manager = new InvoicesManager()
fetch = async (req: Request, res: Response) => {
const data = InvoiceArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchList(data))
}
getById = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.fetchById(id.id)
if (!result) return res.status(404).end()
return res.tvJson(result)
}
create = async (req: Request, res: Response) => {
const data = InvoiceArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.create({ data, createdBy: req.appUser.getUserData()?.id ?? null })
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
return res.tvJson(result.invoice)
}
update = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
const data = InvoiceArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.update({ invoiceId: id.id, data })
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
return res.tvJson(result.invoice)
}
setStatus = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
const data = InvoiceArkTypeStatus(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return this.sendTransition(res, await this.manager.transition({ invoiceId: id.id, status: data.status }))
}
reissue = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.reissue({ invoiceId: id.id, createdBy: req.appUser.getUserData()?.id ?? null })
return this.sendTransition(res, result)
}
pdf = async (req: Request, res: Response) => {
const data = InvoiceArkTypePdf({ ...req.params, ...req.query })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const invoice = await this.manager.fetchById(data.id)
if (!invoice) return res.status(404).end()
const buffer = await this.manager.renderPdf({ invoiceId: data.id, lang: data.lang ?? 'en' })
if (!buffer) return res.status(404).end()
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(invoice.number)}.pdf"`)
return res.send(buffer)
}
private sendTransition(res: Response, result: InvoiceTransitionResult) {
if ('error' in result) return res.status(TRANSITION_ERROR_STATUS[result.error]).json(result)
return res.tvJson(result.invoice)
}
delete = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.delete(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
}
@@ -0,0 +1,255 @@
import type {
CounterpartiesSchemaTypeForSelect,
InvoiceCounterpartySnapshot,
InvoiceSellerSnapshot,
InvoiceStatus,
SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { BillingRepository } from '../billing/BillingRepository'
import { computeInvoiceTotals } from '../../utils/invoiceTotals'
import { InvoicePdfRenderer } from './InvoicePdfRenderer'
import { InvoicesRepository } from './InvoicesRepository'
import {
INVOICE_TEMPLATE_VERSION,
INVOICE_TRANSITIONS,
type InvoiceArgList,
type InvoiceArgUpdate,
type InvoiceCreateArgs,
type InvoiceDeleteResult,
type InvoiceForClient,
type InvoiceMissingRequisite,
type InvoiceReissueArgs,
type InvoiceRenderPdfArgs,
type InvoiceSetStatusArgs,
type InvoiceSnapshots,
type InvoiceStatusPatch,
type InvoiceTransitionResult,
type InvoiceUpdateArgs,
type InvoiceWithLines,
type InvoiceWriteError,
type InvoiceWriteResult,
} from './types'
export class InvoicesManager {
public readonly repository: InvoicesRepository
private readonly billing: BillingRepository
private readonly pdf: InvoicePdfRenderer
constructor() {
this.repository = new InvoicesRepository()
this.billing = new BillingRepository()
this.pdf = new InvoicePdfRenderer()
}
async fetchList(args: InvoiceArgList): Promise<InvoiceForClient[]> {
return (await this.repository.fetchList(args)).map((invoice) => this.toClient(invoice))
}
async fetchById(invoiceId: number): Promise<InvoiceForClient | null> {
const invoice = await this.repository.fetchById(invoiceId)
return invoice ? this.toClient(invoice) : null
}
async create({ data, createdBy }: InvoiceCreateArgs): Promise<InvoiceWriteResult> {
const snapshots = await this.resolveSnapshots(data.organizationId, data)
if (typeof snapshots === 'string') return { error: snapshots }
const created = await this.repository.create({ data, createdBy, ...snapshots })
return this.toWriteResult(created)
}
async update({ invoiceId, data }: InvoiceUpdateArgs): Promise<InvoiceWriteResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return { error: 'not_found' }
if (existing.status !== 'draft') return { error: 'not_draft' }
const snapshots = await this.resolveSnapshots(existing.organizationId, data)
if (typeof snapshots === 'string') return { error: snapshots }
const updated = await this.repository.update({ invoiceId, data, ...snapshots })
return this.toWriteResult(updated)
}
async transition({ invoiceId, status }: InvoiceSetStatusArgs): Promise<InvoiceTransitionResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return { error: 'not_found' }
if (!INVOICE_TRANSITIONS[existing.status].includes(status)) {
return { error: 'invalid_transition', from: existing.status, to: status }
}
const patch: InvoiceStatusPatch = { status }
if (status === 'issued' && existing.status === 'draft') {
const missing = this.missingRequisites(existing)
if (missing.length > 0) return { error: 'missing_requisites', missing }
const totals = computeInvoiceTotals(existing)
patch.issuedAt = new Date()
patch.templateVersion = INVOICE_TEMPLATE_VERSION
patch.subtotal = String(totals.subtotal)
patch.discountAmount = String(totals.discount)
patch.taxAmount = String(totals.tax)
patch.total = String(totals.total)
}
if (status === 'issued' && existing.status === 'paid') patch.paidAt = null
if (status === 'paid') patch.paidAt = new Date()
if (status === 'void') patch.voidedAt = new Date()
const updated = await this.repository.applyStatus({ invoiceId, patch })
return updated ? { invoice: this.toClient(updated) } : { error: 'not_found' }
}
async reissue({ invoiceId, createdBy }: InvoiceReissueArgs): Promise<InvoiceTransitionResult> {
const original = await this.repository.fetchById(invoiceId)
if (!original) return { error: 'not_found' }
if (original.status === 'issued') {
const voided = await this.transition({ invoiceId, status: 'void' })
if ('error' in voided) return voided
} else if (original.status !== 'void') {
return { error: 'invalid_transition', from: original.status, to: 'void' }
}
const number = await this.repository.nextNumber({ organizationId: original.organizationId, base: original.number })
const created = await this.repository.create({
createdBy,
sellerSnapshot: original.sellerSnapshot,
counterpartySnapshot: original.counterpartySnapshot,
goalName: original.goalName,
data: {
organizationId: original.organizationId,
goalId: original.goalId,
sellerId: original.sellerId,
counterpartyId: original.counterpartyId,
number,
reference: original.reference,
currencyCode: original.currencyCode,
issueDate: new Date().toISOString().slice(0, 10),
paymentTerms: original.paymentTerms,
dueDate: original.dueDate,
periodFrom: original.periodFrom,
periodTo: original.periodTo,
discountType: original.discountType,
discountValue: Number(original.discountValue),
taxRate: Number(original.taxRate),
taxExempt: original.taxExempt,
taxNote: original.taxNote,
notes: original.notes,
terms: original.terms,
lines: original.lines.map((line) => ({
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice),
})),
},
})
if (!created || created === 'duplicate_number') return { error: 'not_found' }
await this.repository.setReplaces({ invoiceId: created.id, replacesInvoiceId: original.id })
return { invoice: this.toClient({ ...created, replacesInvoiceId: original.id }) }
}
async delete(invoiceId: number): Promise<InvoiceDeleteResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return 'not_found'
if (existing.status !== 'draft') return 'not_draft'
return (await this.repository.delete(invoiceId)) ? 'deleted' : 'not_found'
}
async renderPdf({ invoiceId, lang }: InvoiceRenderPdfArgs): Promise<Buffer | null> {
const invoice = await this.fetchById(invoiceId)
if (!invoice) return null
return this.pdf.render(invoice, lang)
}
private missingRequisites(invoice: InvoiceWithLines): InvoiceMissingRequisite[] {
const seller = invoice.sellerSnapshot
const counterparty = invoice.counterpartySnapshot
const missing: InvoiceMissingRequisite[] = []
if (!seller.name.trim() && !seller.legalName.trim()) missing.push('seller.name')
if (!seller.address.trim()) missing.push('seller.address')
if (!seller.bank.accountNumber.trim() && !seller.bank.iban.trim()) missing.push('seller.bank')
if (!counterparty.name.trim() && !counterparty.legalName.trim()) missing.push('counterparty.name')
if (!counterparty.address.trim()) missing.push('counterparty.address')
if (invoice.lines.length === 0) missing.push('lines')
return missing
}
private async resolveSnapshots(organizationId: number, data: InvoiceArgUpdate): Promise<InvoiceSnapshots | InvoiceWriteError> {
const seller = await this.billing.fetchSellerById(data.sellerId)
if (!seller || seller.organizationId !== organizationId) return 'seller_not_found'
const counterparty = await this.billing.fetchCounterpartyById(data.counterpartyId)
if (!counterparty || counterparty.organizationId !== organizationId) return 'counterparty_not_found'
let goalName = ''
if (data.goalId !== null) {
const goal = await this.repository.fetchGoal(data.goalId)
if (!goal || goal.organizationId !== organizationId) return 'goal_not_found'
goalName = goal.name ?? ''
}
return {
sellerSnapshot: this.snapshotSeller(seller),
counterpartySnapshot: this.snapshotCounterparty(counterparty),
goalName,
}
}
private snapshotSeller(seller: SellersSchemaTypeForSelect): InvoiceSellerSnapshot {
return {
name: seller.name,
legalName: seller.legalName,
address: seller.address,
email: seller.email,
phone: seller.phone,
logoUrl: seller.logoUrl,
bank: seller.bank,
requisites: seller.requisites,
}
}
private snapshotCounterparty(counterparty: CounterpartiesSchemaTypeForSelect): InvoiceCounterpartySnapshot {
return {
kind: counterparty.kind,
name: counterparty.name,
legalName: counterparty.legalName,
address: counterparty.address,
email: counterparty.email,
phone: counterparty.phone,
contactPerson: counterparty.contactPerson,
requisites: counterparty.requisites,
}
}
private toWriteResult(result: InvoiceWithLines | 'duplicate_number' | null): InvoiceWriteResult {
if (result === 'duplicate_number') return { error: 'duplicate_number' }
if (!result) return { error: 'not_found' }
return { invoice: this.toClient(result) }
}
private toClient(invoice: InvoiceWithLines): InvoiceForClient {
const { sellerSnapshot, counterpartySnapshot, lines, subtotal, discountAmount, taxAmount, total, ...rest } = invoice
const totalsFrozen = total !== null
const totals = totalsFrozen
? {
subtotal: Number(subtotal),
discount: Number(discountAmount),
taxable: Number(subtotal) - Number(discountAmount),
tax: Number(taxAmount),
total: Number(total),
}
: computeInvoiceTotals(invoice)
return {
...rest,
discountValue: Number(invoice.discountValue),
taxRate: Number(invoice.taxRate),
seller: sellerSnapshot,
counterparty: counterpartySnapshot,
totals,
totalsFrozen,
lines: lines.map((line) => ({
id: line.id,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice),
})),
}
}
}
export type { InvoiceStatus }
@@ -0,0 +1,228 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import {
CounterpartiesSchema,
GoalsSchema,
InvoiceLinesSchema,
InvoicesSchema,
type InvoiceLinesSchemaTypeForSelect,
type InvoicesSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
InvoiceArgList,
InvoiceArgUpdate,
InvoiceCreateRepoArgs,
InvoiceLineArg,
InvoiceNextNumberArgs,
InvoiceSetReplacesArgs,
InvoiceStatusRepoArgs,
InvoiceUpdateRepoArgs,
InvoiceWithLines,
} from './types'
const PG_UNIQUE_VIOLATION = '23505'
export class InvoicesRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async fetchList({ organizationId, includeArchived }: InvoiceArgList): Promise<InvoiceWithLines[]> {
const conditions = [eq(InvoicesSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
const rows = await callWithCatch(() =>
this.db.dbDrizzle
.select({ invoice: InvoicesSchema })
.from(InvoicesSchema)
.innerJoin(CounterpartiesSchema, eq(CounterpartiesSchema.id, InvoicesSchema.counterpartyId))
.where(and(...conditions))
.orderBy(desc(InvoicesSchema.issueDate), desc(InvoicesSchema.id)),
)
const invoices = (rows ?? []).map((row) => row.invoice)
return this.attachLines(invoices)
}
async fetchById(invoiceId: number): Promise<InvoiceWithLines | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)),
)
const invoice = result?.[0]
if (!invoice) return null
return (await this.attachLines([invoice]))[0]
}
async fetchOrganizationId(invoiceId: number): Promise<number | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ organizationId: InvoicesSchema.organizationId })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.id, invoiceId)),
)
return result?.[0]?.organizationId ?? null
}
async fetchGoal(goalId: number): Promise<{ organizationId: number | null; name: string | null } | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ organizationId: GoalsSchema.organizationId, name: GoalsSchema.name })
.from(GoalsSchema)
.where(eq(GoalsSchema.id, goalId)),
)
return result?.[0] ?? null
}
private invoiceColumns(data: InvoiceArgUpdate) {
return {
goalId: data.goalId,
sellerId: data.sellerId,
counterpartyId: data.counterpartyId,
number: data.number,
reference: data.reference,
currencyCode: data.currencyCode,
issueDate: data.issueDate,
paymentTerms: data.paymentTerms,
dueDate: data.dueDate,
periodFrom: data.periodFrom,
periodTo: data.periodTo,
discountType: data.discountType,
discountValue: String(data.discountValue),
taxRate: String(data.taxRate),
taxExempt: data.taxExempt,
taxNote: data.taxNote,
notes: data.notes,
terms: data.terms,
}
}
async create({ data, createdBy, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceCreateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const inserted = await tx
.insert(InvoicesSchema)
.values({
organizationId: data.organizationId,
...this.invoiceColumns(data),
goalName,
sellerSnapshot,
counterpartySnapshot,
createdBy,
})
.returning()
const created = inserted[0]
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(created.id, data.lines)).returning()
return { ...created, lines: insertedLines }
})
} catch (error) {
if (this.isUniqueViolation(error)) return 'duplicate_number'
return null
}
}
async update({ invoiceId, data, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceUpdateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const updated = await tx
.update(InvoicesSchema)
.set({
...this.invoiceColumns(data),
goalName,
sellerSnapshot,
counterpartySnapshot,
updatedAt: new Date(),
})
.where(eq(InvoicesSchema.id, invoiceId))
.returning()
const current = updated[0]
if (!current) return null
await tx.delete(InvoiceLinesSchema).where(eq(InvoiceLinesSchema.invoiceId, invoiceId))
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(invoiceId, data.lines)).returning()
return { ...current, lines: insertedLines }
})
} catch (error) {
if (this.isUniqueViolation(error)) return 'duplicate_number'
return null
}
}
async applyStatus({ invoiceId, patch }: InvoiceStatusRepoArgs): Promise<InvoiceWithLines | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(InvoicesSchema)
.set({ ...patch, updatedAt: new Date() })
.where(eq(InvoicesSchema.id, invoiceId))
.returning(),
)
const invoice = result?.[0]
if (!invoice) return null
return (await this.attachLines([invoice]))[0]
}
async setReplaces({ invoiceId, replacesInvoiceId }: InvoiceSetReplacesArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(InvoicesSchema).set({ replacesInvoiceId }).where(eq(InvoicesSchema.id, invoiceId)),
)
}
async nextNumber({ organizationId, base }: InvoiceNextNumberArgs): Promise<string> {
const match = base.match(/^(.*?)(\d+)$/)
if (!match) return `${base}-1`
const [, prefix, digits] = match
const rows = await callWithCatch(() =>
this.db.dbDrizzle
.select({ number: InvoicesSchema.number })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.organizationId, organizationId)),
)
let max = Number(digits)
for (const row of rows ?? []) {
if (!row.number.startsWith(prefix)) continue
const tail = row.number.slice(prefix.length)
if (/^\d+$/.test(tail)) max = Math.max(max, Number(tail))
}
return `${prefix}${String(max + 1).padStart(digits.length, '0')}`
}
async delete(invoiceId: number): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle.delete(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)))
return !!result?.rowCount
}
private toLineRows(invoiceId: number, lines: InvoiceLineArg[]) {
return lines.map((line, index) => ({
invoiceId,
position: index,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: String(line.quantity),
unitPrice: String(line.unitPrice),
}))
}
private async attachLines(invoices: InvoicesSchemaTypeForSelect[]): Promise<InvoiceWithLines[]> {
if (invoices.length === 0) return []
const lines = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(InvoiceLinesSchema)
.where(inArray(InvoiceLinesSchema.invoiceId, invoices.map((invoice) => invoice.id)))
.orderBy(asc(InvoiceLinesSchema.invoiceId), asc(InvoiceLinesSchema.position)),
)
const byInvoice = new Map<number, InvoiceLinesSchemaTypeForSelect[]>()
for (const line of lines ?? []) {
const list = byInvoice.get(line.invoiceId) ?? []
list.push(line)
byInvoice.set(line.invoiceId, list)
}
return invoices.map((invoice) => ({ ...invoice, lines: byInvoice.get(invoice.id) ?? [] }))
}
private isUniqueViolation(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const { code, cause } = error as { code?: string; cause?: { code?: string } }
return code === PG_UNIQUE_VIOLATION || cause?.code === PG_UNIQUE_VIOLATION
}
}
@@ -0,0 +1,36 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
import { InvoicesController } from './InvoicesController'
import { isOrgAdminForInvoice } from './middlewares/is-org-admin-for-invoice'
export default class InvoicesRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: InvoicesController
constructor() {
this.router = Router()
this.controller = new InvoicesController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
this.router.get('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetch)
this.router.post('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.create)
this.router.get('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.getById)
this.router.patch('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.update)
this.router.patch('/:id/status', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.setStatus)
this.router.post('/:id/reissue', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.reissue)
this.router.get('/:id/pdf', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.pdf)
this.router.delete('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.delete)
}
}
@@ -0,0 +1,260 @@
import axios from 'axios'
import type http from 'http'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import App from '../../../App'
const port = 1811
const api = axios.create({ baseURL: `http://localhost:${port}`, validateStatus: () => true })
const LOGIN = 'test@mail.dest'
const PASSWORD = 'user1!#Q'
let server: http.Server
let jwt = ''
let organizationId = 0
let goalId = 0
let sellerId = 0
let counterpartyId = 0
let invoiceId = 0
const auth = () => ({ headers: { Authorization: `Bearer ${jwt}` } })
const sellerPayload = () => ({
organizationId,
name: 'IT Seller',
legalName: 'IT Seller LLC',
address: 'Somewhere 1',
email: 'billing@seller.test',
phone: '',
logoUrl: '',
currencyCode: 'EUR',
bank: { bankName: 'Bank', accountNumber: '123', iban: 'DE00', swift: 'XXX', correspondentAccount: '' },
requisites: [{ key: 'vat', label: 'VAT ID', value: 'DE1' }],
defaultTerms: 'Net 14',
taxNote: '',
})
const counterpartyPayload = () => ({
organizationId,
kind: 'organization',
name: 'IT Client',
legalName: 'IT Client GmbH',
address: '',
email: 'ap@client.test',
phone: '',
contactPerson: 'Anna',
requisites: [],
})
const invoicePayload = (number: string) => ({
organizationId,
goalId,
sellerId,
counterpartyId,
number,
reference: 'PO-1',
currencyCode: 'EUR',
issueDate: '2026-09-01',
paymentTerms: 'net14',
dueDate: '2026-09-15',
periodFrom: null,
periodTo: null,
discountType: 'percent',
discountValue: 10,
taxRate: 19,
taxExempt: false,
taxNote: '',
notes: '',
terms: 'Net 14',
lines: [
{ taskId: null, description: 'Design', unit: 'service', quantity: 1, unitPrice: 1000 },
{ taskId: null, description: 'Dev', unit: 'hours', quantity: 10.5, unitPrice: 80 },
],
})
describe('billing and invoices', () => {
vi.mock('emailjs', () => ({
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
}))
beforeAll(async () => {
server = new App(port).listen()
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD })
expect(login.status).toBe(200)
jwt = login.data.access
const org = await api.post('/module/organizations', { name: `billing-it-${Date.now()}` }, auth())
expect(org.status).toBe(200)
organizationId = org.data.response.id
const goal = await api.post('/module/goals', { name: 'billing-it-goal', organizationId }, auth())
expect(goal.status).toBe(200)
goalId = goal.data.response.id ?? goal.data.response.goal?.id
})
afterAll(async () => {
if (organizationId) await api.delete(`/module/organizations/${organizationId}`, auth())
server?.close()
})
it('lists the seeded currencies', async () => {
const response = await api.get('/module/billing/currencies', auth())
expect(response.status).toBe(200)
expect(response.data.response.length).toBeGreaterThanOrEqual(20)
expect(response.data.response.find((c: { code: string }) => c.code === 'JPY').decimalDigits).toBe(0)
})
it('creates a seller and a counterparty for the organization', async () => {
const seller = await api.post('/module/billing/sellers', sellerPayload(), auth())
expect(seller.status).toBe(200)
sellerId = seller.data.response.id
expect(seller.data.response.bank.iban).toBe('DE00')
const counterparty = await api.post('/module/billing/counterparties', counterpartyPayload(), auth())
expect(counterparty.status).toBe(200)
counterpartyId = counterparty.data.response.id
const list = await api.get('/module/billing/counterparties', { params: { organizationId }, ...auth() })
expect(list.data.response.map((c: { id: number }) => c.id)).toContain(counterpartyId)
})
it('rejects an invalid payload', async () => {
const response = await api.post('/module/billing/sellers', { ...sellerPayload(), currencyCode: 'euro' }, auth())
expect(response.status).toBe(400)
})
it('creates an invoice with snapshots, lines and the project name', async () => {
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
expect(response.status).toBe(200)
const invoice = response.data.response
invoiceId = invoice.id
expect(invoice.status).toBe('draft')
expect(invoice.goalName).toBe('billing-it-goal')
expect(invoice.seller.legalName).toBe('IT Seller LLC')
expect(invoice.counterparty.contactPerson).toBe('Anna')
expect(invoice.lines).toHaveLength(2)
expect(invoice.lines[1].quantity).toBe(10.5)
expect(invoice.discountValue).toBe(10)
expect(typeof invoice.taxRate).toBe('number')
})
it('refuses a duplicate number inside the organization', async () => {
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
expect(response.status).toBe(409)
})
it('refuses a seller from another organization', async () => {
const response = await api.post('/module/invoices', { ...invoicePayload('INV-IT-2'), sellerId: 999999 }, auth())
expect(response.status).toBe(422)
})
it('keeps the seller snapshot when the seller is edited', async () => {
const update = await api.patch(`/module/billing/sellers/${sellerId}`, { ...sellerPayload(), organizationId: undefined, legalName: 'Renamed LLC' }, auth())
expect(update.status).toBe(200)
const invoice = await api.get(`/module/invoices/${invoiceId}`, auth())
expect(invoice.data.response.seller.legalName).toBe('IT Seller LLC')
})
it('replaces lines on update', async () => {
const data = { ...invoicePayload('INV-IT-1'), organizationId: undefined, lines: [{ taskId: null, description: 'Only', unit: 'pcs', quantity: 2, unitPrice: 5 }] }
const response = await api.patch(`/module/invoices/${invoiceId}`, data, auth())
expect(response.status).toBe(200)
expect(response.data.response.lines).toHaveLength(1)
expect(response.data.response.lines[0].description).toBe('Only')
})
it('refuses to delete a seller that has invoices', async () => {
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
expect(response.status).toBe(409)
})
it('hides invoices of an archived client unless asked for', async () => {
const archive = await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: true }, auth())
expect(archive.status).toBe(200)
expect(archive.data.response.archived).toBe(true)
const hidden = await api.get('/module/invoices', { params: { organizationId }, ...auth() })
expect(hidden.data.response.map((i: { id: number }) => i.id)).not.toContain(invoiceId)
const shown = await api.get('/module/invoices', { params: { organizationId, includeArchived: true }, ...auth() })
expect(shown.data.response.map((i: { id: number }) => i.id)).toContain(invoiceId)
await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: false }, auth())
})
it('refuses to pay a draft and reports what is missing before issuing', async () => {
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
expect(paid.status).toBe(409)
expect(paid.data.error).toBe('invalid_transition')
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(issued.status).toBe(422)
expect(issued.data.missing).toContain('counterparty.address')
})
it('issues the invoice, freezes the totals and locks editing', async () => {
const fixed = await api.patch(`/module/billing/counterparties/${counterpartyId}`, { ...counterpartyPayload(), organizationId: undefined, address: 'Client street 1' }, auth())
expect(fixed.status).toBe(200)
const resnap = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
expect(resnap.status).toBe(200)
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(issued.status).toBe(200)
expect(issued.data.response.status).toBe('issued')
expect(issued.data.response.issuedAt).toBeTruthy()
expect(issued.data.response.totalsFrozen).toBe(true)
expect(issued.data.response.totals.total).toBe(1970.64)
const edit = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
expect(edit.status).toBe(409)
const del = await api.delete(`/module/invoices/${invoiceId}`, auth())
expect(del.status).toBe(409)
const back = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'draft' }, auth())
expect(back.status).toBe(409)
})
it('marks paid, unmarks and refuses to void a paid invoice', async () => {
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
expect(paid.data.response.paidAt).toBeTruthy()
const voided = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'void' }, auth())
expect(voided.status).toBe(409)
const unpaid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(unpaid.data.response.paidAt).toBeNull()
})
it('renders the invoice as a PDF', async () => {
const response = await api.get(`/module/invoices/${invoiceId}/pdf`, { ...auth(), params: { lang: 'ru' }, responseType: 'arraybuffer' })
expect(response.status).toBe(200)
expect(response.headers['content-type']).toContain('application/pdf')
expect(Buffer.from(response.data).subarray(0, 4).toString()).toBe('%PDF')
})
it('reissues: voids the original and creates a draft copy with the next number', async () => {
const response = await api.post(`/module/invoices/${invoiceId}/reissue`, {}, auth())
expect(response.status).toBe(200)
const copy = response.data.response
expect(copy.status).toBe('draft')
expect(copy.number).toBe('INV-IT-2')
expect(copy.replacesInvoiceId).toBe(invoiceId)
expect(copy.lines).toHaveLength(2)
const original = await api.get(`/module/invoices/${invoiceId}`, auth())
expect(original.data.response.status).toBe('void')
expect(original.data.response.voidedAt).toBeTruthy()
const revive = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(revive.status).toBe(409)
const deleted = await api.delete(`/module/invoices/${copy.id}`, auth())
expect(deleted.status).toBe(200)
})
it('keeps a seller with a voided invoice undeletable', async () => {
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
expect(response.status).toBe(409)
})
it('rejects an anonymous request', async () => {
const response = await api.get('/module/invoices', { params: { organizationId } })
expect(response.status).toBe(401)
})
})
@@ -0,0 +1,6 @@
import { isOrgAdminFor } from '../../billing/middlewares/is-org-admin-for'
import { InvoicesRepository } from '../InvoicesRepository'
const repository = new InvoicesRepository()
export const isOrgAdminForInvoice = isOrgAdminFor((id) => repository.fetchOrganizationId(id))
+203
View File
@@ -0,0 +1,203 @@
import { type } from 'arktype'
import type {
InvoiceCounterpartySnapshot,
InvoiceLinesSchemaTypeForSelect,
InvoiceSellerSnapshot,
InvoiceStatus,
InvoicesSchemaTypeForSelect,
} from 'taskview-db-schemas'
import type { InvoiceTotalsResult } from '../../utils/invoiceTotals'
const NumberFromString = type('string|number').pipe((v) => Number(v))
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
const DateString = type(/^\d{4}-\d{2}-\d{2}$/)
const DateStringOrNull = DateString.or('null')
export const InvoiceLineArkType = type({
taskId: 'number|null',
description: '1<=string<=1000',
unit: "'service' | 'hours' | 'pcs'",
quantity: 'number>=0',
unitPrice: 'number>=0',
})
export type InvoiceLineArg = typeof InvoiceLineArkType.infer
export const InvoiceArkTypeCreate = type({
organizationId: 'number',
goalId: 'number|null',
sellerId: 'number',
counterpartyId: 'number',
number: '1<=string<=50',
reference: 'string<=200',
currencyCode: /^[A-Z]{3}$/,
issueDate: DateString,
paymentTerms: "'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'",
dueDate: DateStringOrNull,
periodFrom: DateStringOrNull,
periodTo: DateStringOrNull,
discountType: "'percent' | 'amount'",
discountValue: 'number>=0',
taxRate: '0<=number<=100',
taxExempt: 'boolean',
taxNote: 'string<=500',
notes: 'string<=2000',
terms: 'string<=2000',
lines: InvoiceLineArkType.array().atLeastLength(1),
})
export type InvoiceArgCreate = typeof InvoiceArkTypeCreate.infer
export const InvoiceArkTypeUpdate = InvoiceArkTypeCreate.omit('organizationId')
export type InvoiceArgUpdate = typeof InvoiceArkTypeUpdate.infer
export const InvoiceArkTypeList = type({
organizationId: NumberFromString,
'includeArchived?': BooleanFromString,
})
export type InvoiceArgList = typeof InvoiceArkTypeList.infer
export const InvoiceArkTypeId = type({
id: NumberFromString,
})
export const InvoiceArkTypeStatus = type({
status: "'draft' | 'issued' | 'paid' | 'void'",
})
export type InvoiceArgStatus = typeof InvoiceArkTypeStatus.infer
export const InvoiceArkTypePdf = type({
id: NumberFromString,
'lang?': "'en' | 'ru'",
})
export type InvoiceArgPdf = typeof InvoiceArkTypePdf.infer
export const INVOICE_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
draft: ['issued'],
issued: ['paid', 'void'],
paid: ['issued'],
void: [],
}
export const INVOICE_TEMPLATE_VERSION = 1
export type InvoiceMissingRequisite =
| 'seller.name'
| 'seller.address'
| 'seller.bank'
| 'counterparty.name'
| 'counterparty.address'
| 'lines'
export type InvoiceStatusPatch = {
status: InvoiceStatus
issuedAt?: Date | null
paidAt?: Date | null
voidedAt?: Date | null
templateVersion?: number
subtotal?: string | null
discountAmount?: string | null
taxAmount?: string | null
total?: string | null
}
export type InvoiceStatusRepoArgs = {
invoiceId: number
patch: InvoiceStatusPatch
}
export type InvoiceSetReplacesArgs = {
invoiceId: number
replacesInvoiceId: number
}
export type InvoiceReissueArgs = {
invoiceId: number
createdBy: number | null
}
export type InvoiceTransitionError =
| { error: 'not_found' }
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
export type InvoiceTransitionResult = { invoice: InvoiceForClient } | InvoiceTransitionError
export type InvoicePdfLang = 'en' | 'ru'
export type InvoiceRenderPdfArgs = {
invoiceId: number
lang: InvoicePdfLang
}
export type PdfmakeServer = {
virtualfs: { writeFileSync(filename: string, content: Buffer): void }
setFonts(fonts: Record<string, { normal: string; bold: string; italics: string; bolditalics: string }>): void
createPdf(docDefinition: unknown): { getBuffer(): Promise<Buffer> }
}
export type InvoiceSnapshots = {
sellerSnapshot: InvoiceSellerSnapshot
counterpartySnapshot: InvoiceCounterpartySnapshot
goalName: string
}
export type InvoiceCreateRepoArgs = InvoiceSnapshots & {
data: InvoiceArgCreate
createdBy: number | null
}
export type InvoiceUpdateRepoArgs = InvoiceSnapshots & {
invoiceId: number
data: InvoiceArgUpdate
}
export type InvoiceCreateArgs = {
data: InvoiceArgCreate
createdBy: number | null
}
export type InvoiceUpdateArgs = {
invoiceId: number
data: InvoiceArgUpdate
}
export type InvoiceSetStatusArgs = {
invoiceId: number
status: InvoiceArgStatus['status']
}
export type InvoiceWithLines = InvoicesSchemaTypeForSelect & {
lines: InvoiceLinesSchemaTypeForSelect[]
}
export type InvoiceLineForClient = Omit<InvoiceLinesSchemaTypeForSelect, 'invoiceId' | 'position' | 'quantity' | 'unitPrice'> & {
quantity: number
unitPrice: number
}
export type InvoiceForClient = Omit<
InvoicesSchemaTypeForSelect,
'discountValue' | 'taxRate' | 'sellerSnapshot' | 'counterpartySnapshot' | 'subtotal' | 'discountAmount' | 'taxAmount' | 'total'
> & {
discountValue: number
taxRate: number
seller: InvoiceSellerSnapshot
counterparty: InvoiceCounterpartySnapshot
lines: InvoiceLineForClient[]
totals: InvoiceTotalsResult
totalsFrozen: boolean
}
export type InvoiceWriteError = 'seller_not_found' | 'counterparty_not_found' | 'goal_not_found' | 'duplicate_number' | 'not_found' | 'not_draft'
export type InvoiceWriteResult = { invoice: InvoiceForClient } | { error: InvoiceWriteError }
export type InvoiceDeleteResult = 'deleted' | 'not_draft' | 'not_found'
export type InvoiceNextNumberArgs = {
organizationId: number
base: string
}
+84 -11
View File
@@ -1,10 +1,11 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { GoalPermissions } from '../../types/auth.types';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { KanbanController } from './KanbanController';
import { CanManageKanban } from './middlewares/CanManageKanban';
import { CanViewKanban } from './middlewares/CanViewKanban';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { goalIdFromBody, goalIdFromParam, goalIdFromStatusBody } from './middlewares/goal-id-resolvers';
import { requireKanbanPermission } from './middlewares/require-kanban-permission';
export default class KanbanRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly kanbanController: KanbanController;
@@ -20,17 +21,89 @@ export default class KanbanRoutes implements Routable {
}
initRoutes() {
this.router.post('/fetch-statuses', [IsLoggedIn, CanViewKanban], this.kanbanController.fetchAllColumns);
this.router.post('/add-status', [IsLoggedIn, CanManageKanban], this.kanbanController.addStatus);
this.router.post('/delete-status', [IsLoggedIn, CanManageKanban], this.kanbanController.deleteStatus);
this.router.post('/update-status', [IsLoggedIn, CanManageKanban], this.kanbanController.updateStatus);
this.router.post(
'/fetch-statuses',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromBody,
}),
],
this.kanbanController.fetchAllColumns
);
// this.router.get('columns/:goalId', [IsLoggedIn], this.kanbanController.fetchAllColumns);
this.router.get('/tasks/:goalId/:columnId/:cursor', [IsLoggedIn, CanViewKanban, CanFetchTasks], this.kanbanController.fetchTasksForColumn);
this.router.post(
'/add-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.addStatus
);
this.router.post(
'/delete-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.deleteStatus
);
this.router.post(
'/update-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.updateStatus
);
this.router.get(
'/tasks/:goalId/:columnId/:cursor',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam,
}),
requireKanbanPermission({
anyOf: [GoalPermissions.COMPONENT_CAN_WATCH_CONTENT],
resolveGoalId: goalIdFromParam,
}),
],
this.kanbanController.fetchTasksForColumn
);
//we do not use this route in the client (no logic for this route on the client side)!!!
this.router.get('/tasks-order/:goalId/:columnId/:cursor', [IsLoggedIn, CanManageKanban], this.kanbanController.getTasksOrderForColumnAndCursor);
this.router.get(
'/tasks-order/:goalId/:columnId/:cursor',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam
})
],
this.kanbanController.getTasksOrderForColumnAndCursor
);
this.router.patch('/update-tasks-order-and-column', [IsLoggedIn, CanManageKanban], this.kanbanController.updateTasksOrderAndColumn);
this.router.patch(
'/update-tasks-order-and-column',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.updateTasksOrderAndColumn
);
}
}

Some files were not shown because too many files have changed in this diff Show More