mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5976a03ce0 | |||
| 3403e1a71d | |||
| e9fb4f6f1e | |||
| a173a870d7 | |||
| 171f269ee2 | |||
| f79614ebc5 | |||
| a4c465469d | |||
| f3e2663ebe | |||
| 5ce2fe99f0 | |||
| e11aba4d79 | |||
| 0b6f95ec3d | |||
| a596e023db | |||
| c1685e42b8 | |||
| 9635120e66 | |||
| 19e0212edf | |||
| 5dc5b387de | |||
| 8011da1259 | |||
| a6329e998d | |||
| b372854636 | |||
| a243f9ec56 | |||
| ebd25a94ea | |||
| 4b142619c0 | |||
| 6f576e7297 | |||
| 15050541fa | |||
| 3afdca99d9 | |||
| aaa876412d | |||
| 915e8d9f9f | |||
| 55ded8bac9 | |||
| 784652ef5b | |||
| 1f1a1b770f | |||
| e80ab33dda | |||
| 8c7be7362f | |||
| 57ec7c01b6 | |||
| d0f664f78e | |||
| 7bbb36d45e | |||
| 9934bf06d8 | |||
| fd13b33915 | |||
| 64089fd6e7 | |||
| b7a50049bb | |||
| 504ae503dd | |||
| 263883f32d | |||
| 90b55fd82a | |||
| 2e9a4945ce | |||
| 0d70023fb3 | |||
| 645f2e21e5 | |||
| adce016a05 | |||
| b7f2380d45 | |||
| 58eb93e564 | |||
| 4e5e5fb579 | |||
| 314e5a6377 | |||
| 955697f40f | |||
| e5dbba8e2a | |||
| bb28bac9f7 | |||
| 6c990aff4f | |||
| b296046605 | |||
| 40cd8064f5 | |||
| f4a765f40b | |||
| b9ce7261f0 | |||
| bc08c839bc | |||
| 83979ac47c | |||
| a9be0d987a | |||
| 30274069a0 | |||
| 0a8497af59 | |||
| 284a49ce8c | |||
| 64a227303e | |||
| ae88d0f42a | |||
| d40cc0aa1b | |||
| e99d9d5515 | |||
| 9b38e3cd9d | |||
| 9c6d33cefe | |||
| d008fa4f78 | |||
| 8ff7241645 | |||
| 9bdfe679bd | |||
| 4e5a330a27 | |||
| e5860a3816 | |||
| 0f4c484674 | |||
| e025c212e5 | |||
| 9b08c82548 | |||
| 3d9cb5336a | |||
| 8eb94d0d2b | |||
| 78c910f789 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "taskview",
|
||||
"owner": {
|
||||
"name": "Nikolai Giman"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "taskview",
|
||||
"source": "./taskview-plugin",
|
||||
"description": "Manage TaskView projects and tasks from Claude Code. Bundles the TaskView MCP server, skills, and slash commands."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ ReleasesBuilds/*
|
||||
iconcreator
|
||||
*.prod.*
|
||||
.DS_Store
|
||||
.cursor
|
||||
.claude
|
||||
node_modules
|
||||
__APP_BUILD__
|
||||
ssl-create
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# TaskView™
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
|
||||
<img src="./assets/taskview/kanban-dark.png" alt="TaskView — Kanban board" width="1440" style="max-width: 100%;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary><strong>View more screenshots</strong></summary>
|
||||
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -60,6 +60,15 @@
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
|
||||
</a>
|
||||
<a href="https://github.com/Gimanh/taskview-community/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Gimanh/taskview-community?label=release&color=brightgreen" alt="Latest release">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/taskview-mcp">
|
||||
<img src="https://img.shields.io/npm/v/taskview-mcp?label=taskview-mcp&logo=npm&color=CB3837" alt="taskview-mcp on npm">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/taskview-api">
|
||||
<img src="https://img.shields.io/npm/v/taskview-api?label=taskview-api&logo=npm&color=CB3837" alt="taskview-api on npm">
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
|
||||
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
|
||||
</p>
|
||||
@@ -169,7 +178,37 @@ Depending on the permissions assigned to an API token, an AI assistant can:
|
||||
|
||||
MCP access can be restricted by permission and by selected projects.
|
||||
|
||||
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
|
||||
### Connecting an MCP client
|
||||
|
||||
The MCP server is published as [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) and runs over stdio via `npx` — no install required. You only need a TaskView API token (`tvk_...`) — generate one in your account settings (see [API tokens](https://taskview.tech/docs/features/api-tokens)). Scope the token to the minimum permissions and projects the assistant should reach.
|
||||
|
||||
**Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskview": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "taskview-mcp"],
|
||||
"env": {
|
||||
"TASKVIEW_URL": "https://api.taskview.tech",
|
||||
"TASKVIEW_TOKEN": "tvk_your_token_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop** — add the same `mcpServers` block to `claude_desktop_config.json`.
|
||||
|
||||
**Other MCP clients** (Cursor, Windsurf, etc.) — use the same stdio command `npx -y taskview-mcp` with the `TASKVIEW_URL` and `TASKVIEW_TOKEN` environment variables in that client's MCP configuration.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`, or your self-hosted instance) |
|
||||
| `TASKVIEW_TOKEN` | yes | API token with the `tvk_` prefix |
|
||||
|
||||
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) and the [`taskview-mcp` package README](taskview-packages/taskview-mcp/README.md) for the full tool list and more options.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
instances,
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_memory_restart: '1G',
|
||||
env_production: {
|
||||
|
||||
+27
-1
@@ -5,6 +5,9 @@ import { corsMiddleware } from './middlewares/cors';
|
||||
import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import { LoginMethods } from './tv-modules/auth/LoginMethods';
|
||||
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
|
||||
import { PublicApiUrl } from './modules/public-url';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
@@ -13,6 +16,10 @@ export default class App {
|
||||
public port: number;
|
||||
|
||||
constructor(port: number) {
|
||||
LoginMethods.validateOnStartup();
|
||||
PublicApiUrl.validateOnStartup();
|
||||
InviteEmailDispatcher.validateOnStartup();
|
||||
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
|
||||
@@ -31,7 +38,17 @@ export default class App {
|
||||
protected extendApp(): void { }
|
||||
protected extendMiddlewares(): void { }
|
||||
|
||||
private resolveTrustProxy(): boolean | number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw || raw.toLowerCase() === 'false') return false;
|
||||
if (raw.toLowerCase() === 'true') return true;
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private initializeMiddlewares() {
|
||||
this.app.set('trust proxy', this.resolveTrustProxy());
|
||||
|
||||
//add tvJson method, clien need response format like {response: data}
|
||||
this.app.use((_req: Request, res: Response, next) => {
|
||||
res.tvJson = function (data: any) {
|
||||
@@ -51,7 +68,16 @@ export default class App {
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
this.app.use(express.urlencoded({
|
||||
extended: true,
|
||||
verify: (req: any, _res, buf) => {
|
||||
// Slack sends slash commands / interactivity as urlencoded; keep the raw body
|
||||
// for HMAC signature verification (VerifySlackRequest).
|
||||
if (req.url?.includes('/messaging/slack/')) {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(appUserMiddleware);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
|
||||
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
@@ -8,7 +9,7 @@ export interface AppEvents {
|
||||
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
|
||||
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
|
||||
|
||||
@@ -6,6 +6,8 @@ import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
|
||||
import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTrackingDispatcher';
|
||||
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
|
||||
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
|
||||
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
|
||||
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
@@ -14,6 +16,8 @@ const dispatchers: Dispatcher[] = [
|
||||
new TimeTrackingDispatcher(),
|
||||
new SprintsDispatcher(),
|
||||
new RecurrenceDispatcher(),
|
||||
new MessagingDispatcher(),
|
||||
new InviteEmailDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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', 'zł', 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);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export class PublicApiUrl {
|
||||
static configured(): string | null {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
return raw.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
static base(req: Request): string {
|
||||
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router, type NextFunction, type Request, type Response } from 'express'
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import AuthController from './AuthController';
|
||||
import { IsLoggedIn } from './middlewares/is-logged-in';
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth';
|
||||
import { RequireAnyLoginMethod, RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
|
||||
import passport from './strategies/passport-login';
|
||||
import { ExternalProviderScope } from './strategies/external-auth.types';
|
||||
export default class AuthRoutes implements Routable {
|
||||
@@ -19,13 +21,20 @@ export default class AuthRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('/send-login-code', this.authController.sendLoginCode);
|
||||
this.router.post('/login-by-code', this.authController.loginByCode);
|
||||
this.router.post('/login', this.authController.login);
|
||||
this.router.get('/login-options', this.authController.getLoginOptions);
|
||||
this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode);
|
||||
// Shared one-time-code redemption: magic-link emails, SSO callbacks and social
|
||||
// OAuth callbacks all complete the login through this endpoint
|
||||
this.router.post('/login-by-code', [RequireAnyLoginMethod(['magic-link', 'sso', 'social'])], this.authController.loginByCode);
|
||||
this.router.post('/login', [RequireLoginMethod('password')], this.authController.login);
|
||||
this.router.post('/registration', this.authController.registration);
|
||||
this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail);
|
||||
this.router.post('/email/recovery', this.authController.remindPassword);
|
||||
this.router.post('/password/reset', this.authController.changeRemindedPassword);
|
||||
this.router.post('/email/recovery', [RequireLoginMethod('password')], this.authController.remindPassword);
|
||||
this.router.post('/password/reset', [RequireLoginMethod('password')], this.authController.changeRemindedPassword);
|
||||
this.router.get('/password/change/mode', [IsLoggedIn], this.authController.getPasswordChangeMode);
|
||||
this.router.post('/password/change/code', [IsLoggedIn, RejectApiTokenAuth], this.authController.sendPasswordChangeCode);
|
||||
this.router.post('/password/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeOwnPassword);
|
||||
this.router.post('/credentials/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeDefaultUserCredentials);
|
||||
this.router.post('/logout', [IsLoggedIn], this.authController.logout);
|
||||
this.router.post('/refresh/token', this.authController.refreshTokens);
|
||||
this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode);
|
||||
@@ -33,6 +42,7 @@ export default class AuthRoutes implements Routable {
|
||||
|
||||
this.router.get(
|
||||
'/provider/:providerName',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName],
|
||||
session: false,
|
||||
@@ -43,6 +53,7 @@ export default class AuthRoutes implements Routable {
|
||||
);
|
||||
this.router.get(
|
||||
'/provider/:providerName/callback',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
@@ -51,6 +62,7 @@ export default class AuthRoutes implements Routable {
|
||||
|
||||
this.router.post(
|
||||
'/provider/:providerName/callback',
|
||||
RequireSocialProvider,
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LoginMethod } from '../../types/auth.types';
|
||||
|
||||
export class LoginMethods {
|
||||
static readonly ALL: LoginMethod[] = ['magic-link', 'password', 'sso', 'social'];
|
||||
|
||||
static enabled(): Set<LoginMethod> {
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) {
|
||||
return new Set(LoginMethods.ALL);
|
||||
}
|
||||
return new Set(LoginMethods.parse(raw).valid);
|
||||
}
|
||||
|
||||
static isEnabled(method: LoginMethod): boolean {
|
||||
return LoginMethods.enabled().has(method);
|
||||
}
|
||||
|
||||
static publicRegistrationAllowed(): boolean {
|
||||
return process.env.ALLOW_PUBLIC_REGISTRATION?.trim().toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const registrationRaw = process.env.ALLOW_PUBLIC_REGISTRATION;
|
||||
if (registrationRaw !== undefined && registrationRaw.trim() !== '') {
|
||||
const normalized = registrationRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(
|
||||
`ALLOW_PUBLIC_REGISTRATION has unrecognized value "${registrationRaw}". Allowed: true, false`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
const { valid, invalid } = LoginMethods.parse(raw);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`AUTH_LOGIN_METHODS contains unknown values: ${invalid.join(', ')}. Allowed: ${LoginMethods.ALL.join(', ')}`
|
||||
);
|
||||
}
|
||||
if (valid.length === 0) {
|
||||
throw new Error('AUTH_LOGIN_METHODS disables every login method — nobody would be able to sign in');
|
||||
}
|
||||
}
|
||||
|
||||
static configuredSocialProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_CALLBACK_URL) {
|
||||
providers.push('google');
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL) {
|
||||
providers.push('github');
|
||||
}
|
||||
if (
|
||||
process.env.APPLE_CLIENT_ID &&
|
||||
process.env.APPLE_TEAM_ID &&
|
||||
process.env.APPLE_KEY_ID &&
|
||||
process.env.APPLE_CALLBACK_URL &&
|
||||
process.env.APPLE_KEY_LOCATION
|
||||
) {
|
||||
providers.push('apple');
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
static availableSocialProviders(): string[] {
|
||||
return LoginMethods.isEnabled('social') ? LoginMethods.configuredSocialProviders() : [];
|
||||
}
|
||||
|
||||
private static parse(raw: string): { valid: LoginMethod[]; invalid: string[] } {
|
||||
const values = raw
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const valid = values.filter((value): value is LoginMethod => (LoginMethods.ALL as string[]).includes(value));
|
||||
const invalid = values.filter((value) => !(LoginMethods.ALL as string[]).includes(value));
|
||||
return { valid, invalid };
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
+18
-1
@@ -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('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Bob & "Co"');
|
||||
});
|
||||
|
||||
it('is immune to $-patterns and placeholder strings in user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'Project $` name', organizationId: null }],
|
||||
[{ login: '{link}' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain('Project $` name');
|
||||
expect(html).toContain('{link}');
|
||||
expect(html).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('truncates overlong user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'p'.repeat(200), organizationId: null }],
|
||||
[{ login: 'i'.repeat(200) }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toContain(`"${'p'.repeat(80)}…"`);
|
||||
expect(message.text).toContain(`${'i'.repeat(80)}… has invited`);
|
||||
});
|
||||
|
||||
it('does not send when the goal no longer exists', async () => {
|
||||
mockDb([[]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateOnStartup rejects unrecognized values', () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'ture';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_ENABLED');
|
||||
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = 'abc';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '0';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '10';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import type { CollaborationUsersSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export const CollaborationArkTypeAddUser = type({
|
||||
goalId: 'number',
|
||||
@@ -97,3 +98,30 @@ export const CollaborationArkTypeToggleRolePermission = type({
|
||||
});
|
||||
|
||||
export type CollaborationArgToggleRolePermission = typeof CollaborationArkTypeToggleRolePermission.infer;
|
||||
|
||||
// created=false means the person was already a collaborator of the goal — no invitation happened
|
||||
export type CollaborationAddUserRepoResult = {
|
||||
user: CollaborationUsersSchemaTypeForSelect;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type CollaborationAddUserResult = {
|
||||
user: CollaborationUserWithRoles;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type InviteEmailLocale = 'en' | 'ru';
|
||||
|
||||
export type InviteEmailSendArgs = {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
goalName: string;
|
||||
link: string;
|
||||
locale: InviteEmailLocale;
|
||||
};
|
||||
|
||||
export type InviteEmailRateLimitArgs = {
|
||||
initiatorId: number;
|
||||
email: string;
|
||||
goalId: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Project invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited to a project</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> has invited you to join the project<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Open project</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">If the button doesn't work, copy this link into your browser:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">You received this email because someone invited you to a project on TaskView. If you weren't expecting it, you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Приглашение в проект</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Вас пригласили в проект</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> приглашает вас присоединиться к проекту<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Открыть проект</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -63,8 +63,16 @@ export default class GoalsManager {
|
||||
return new GoalItemForClient(goal, (await this.getPermissionsForGoal(goal.id)).getAllPermissions());
|
||||
}
|
||||
|
||||
private async isInboxGoal(goalId: number): Promise<boolean> {
|
||||
const goal = await this.goalsRepository.findGoalById(goalId);
|
||||
return !!goal && goal.isInbox;
|
||||
}
|
||||
|
||||
/** @deprecated use deleteGoalNew instead */
|
||||
async deleteGoal(goalId: number): Promise<boolean> {
|
||||
if (await this.isInboxGoal(goalId)) {
|
||||
return false;
|
||||
}
|
||||
return await this.goalsRepository.deleteGoal(goalId);
|
||||
}
|
||||
|
||||
@@ -88,6 +96,9 @@ export default class GoalsManager {
|
||||
}
|
||||
|
||||
async updateArchive(goalId: number, archive: GoalItemInDb['archive']) {
|
||||
if (archive === 1 && (await this.isInboxGoal(goalId))) {
|
||||
return false;
|
||||
}
|
||||
return await this.goalsRepository.updateArchive(goalId, archive);
|
||||
}
|
||||
|
||||
@@ -134,6 +145,12 @@ export default class GoalsManager {
|
||||
}
|
||||
|
||||
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsItemForClientWithPermissions | false> {
|
||||
// The Inbox must never be archived. Archiving flows through this endpoint
|
||||
// (PATCH /module/goals), not the unrouted updateArchive, so the guard lives here.
|
||||
if (goalData.archive === 1 && (await this.isInboxGoal(goalData.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const goal = await this.goalsRepository.updateGoalNew(goalData);
|
||||
|
||||
if (!goal) {
|
||||
@@ -153,6 +170,9 @@ export default class GoalsManager {
|
||||
}
|
||||
|
||||
async deleteGoalNew(goalData: GoalsArgDelete) {
|
||||
if (await this.isInboxGoal(goalData.goalId)) {
|
||||
return false;
|
||||
}
|
||||
return await this.goalsRepository.deleteGoalNew(goalData);
|
||||
}
|
||||
|
||||
@@ -217,7 +237,7 @@ export default class GoalsManager {
|
||||
|
||||
await this.user.collaborationManager.repository.toggleUserRolesNew({
|
||||
goalId,
|
||||
userId: collabUser.id,
|
||||
userId: collabUser.user.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AddGoalToDbArg, GoalItemInDb, GoalItemsInDb, UpdateGoalDbArg } fro
|
||||
import { logError } from '../../utils/api';
|
||||
import { updateQuery } from '../../utils/db-helper';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { GoalsArgAdd, GoalsArgDelete, GoalsArgUpdate } from './types';
|
||||
import type { GoalsArgAdd, GoalsArgCreateInbox, GoalsArgDelete, GoalsArgUpdate } from './types';
|
||||
|
||||
export class GoalsRepository {
|
||||
private readonly db: Database;
|
||||
@@ -157,6 +157,7 @@ export class GoalsRepository {
|
||||
backlogVersion: GoalsSchema.backlogVersion,
|
||||
organizationId: GoalsSchema.organizationId,
|
||||
estimateUnit: GoalsSchema.estimateUnit,
|
||||
isInbox: GoalsSchema.isInbox,
|
||||
})
|
||||
.from(GoalsSchema)
|
||||
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
|
||||
@@ -232,9 +233,56 @@ export class GoalsRepository {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
async findInboxGoal(organizationId: number): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(GoalsSchema).set(goalData).where(eq(GoalsSchema.id, goalData.id)).returning()
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(GoalsSchema)
|
||||
.where(and(eq(GoalsSchema.organizationId, organizationId), eq(GoalsSchema.isInbox, true)))
|
||||
);
|
||||
if (!result || result.length === 0) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createInboxGoal(args: GoalsArgCreateInbox): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
const existing = await this.findInboxGoal(args.organizationId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(GoalsSchema)
|
||||
.values({
|
||||
name: 'Inbox',
|
||||
owner: args.ownerId,
|
||||
organizationId: args.organizationId,
|
||||
isInbox: true,
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
|
||||
const updates: Partial<typeof GoalsSchema.$inferInsert> = {};
|
||||
if (goalData.name !== undefined) updates.name = goalData.name;
|
||||
if (goalData.description !== undefined) updates.description = goalData.description;
|
||||
if (goalData.color !== undefined) updates.color = goalData.color;
|
||||
if (goalData.estimateUnit !== undefined) updates.estimateUnit = goalData.estimateUnit;
|
||||
if (goalData.archive !== undefined) updates.archive = goalData.archive;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return this.findGoalById(goalData.id);
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(GoalsSchema).set(updates).where(eq(GoalsSchema.id, goalData.id)).returning()
|
||||
);
|
||||
if (!result) {
|
||||
return false;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const GoalsArkTypeUpdate = type({
|
||||
'description?': 'string | null',
|
||||
'color?': 'string | null',
|
||||
"estimateUnit?": "'hours' | 'points'",
|
||||
'archive?': '0 | 1',
|
||||
});
|
||||
|
||||
export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer;
|
||||
@@ -34,3 +35,8 @@ export const GoalsArkTypeFetch = type({
|
||||
export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer;
|
||||
|
||||
export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient };
|
||||
|
||||
export type GoalsArgCreateInbox = {
|
||||
ownerId: number;
|
||||
organizationId: number;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user