mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4412ba1adf | |||
| 2ad29b77f9 | |||
| 9307ab5e45 | |||
| 2f4e84b54b | |||
| c403673f4d | |||
| 5c4859743e | |||
| 0bf0052909 | |||
| 0e1455b947 | |||
| 63e2481b9d | |||
| ade7c5d007 | |||
| fc3d03f932 | |||
| 9de2b64acf | |||
| ef206635f6 | |||
| b8b8481fb4 | |||
| 71f3385842 | |||
| cf35720093 | |||
| 3ef150add9 | |||
| 626b532d2e | |||
| e5dfd74967 | |||
| cb8f02af4f | |||
| 996dd11af9 | |||
| 9a33837ffd | |||
| 7b24da0688 | |||
| 009d252651 | |||
| 3c7733e5b0 | |||
| 2fa8bd5227 | |||
| 37039278c4 |
+33
-1
@@ -11,6 +11,7 @@ DB_PORT=5432
|
||||
# Application
|
||||
APP_PORT=1401
|
||||
APP_URL=http://localhost:3000
|
||||
API_URL=http://localhost:1401
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SIGN=your_jwt_secret_here
|
||||
@@ -25,4 +26,35 @@ SMTP_USERNAME=your_email@example.com
|
||||
SMTP_PASSWORD=your_smtp_password_here
|
||||
SMTP_ENCRYPTION=ssl
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
|
||||
# Encryption (32-byte hex key for AES-256-GCM)
|
||||
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# GitHub Integration OAuth (separate from login OAuth)
|
||||
GITHUB_INTEGRATION_CLIENT_ID=
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/github/callback
|
||||
# For GitHub Enterprise, override these:
|
||||
# GITHUB_BASE_URL=https://github.yourcompany.com
|
||||
# GITHUB_API_URL=https://github.yourcompany.com/api/v3
|
||||
|
||||
# GitLab Integration OAuth
|
||||
GITLAB_INTEGRATION_CLIENT_ID=
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
# For self-hosted GitLab, override these:
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
# Centrifugo (real-time notifications, optional)
|
||||
# CENTRIFUGO_API_URL=http://localhost:8000
|
||||
# CENTRIFUGO_API_KEY=your_centrifugo_api_key_here
|
||||
# CENTRIFUGO_TOKEN_SECRET=your_centrifugo_token_secret_here
|
||||
# Public port that clients use to connect to Centrifugo (exposed port, not internal docker port)
|
||||
# CENTRIFUGO_PUBLIC_PORT=8000
|
||||
@@ -0,0 +1,6 @@
|
||||
CENTRIFUGO_TOKEN_HMAC_SECRET_KEY=taskview-centrifugo-secret-change-me
|
||||
CENTRIFUGO_API_KEY=taskview-centrifugo-api-key-change-me
|
||||
CENTRIFUGO_ALLOWED_ORIGINS=*
|
||||
CENTRIFUGO_ADMIN=true
|
||||
CENTRIFUGO_ADMIN_PASSWORD=admin
|
||||
CENTRIFUGO_ADMIN_SECRET=admin-secret-change-me
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"allow_subscribe_for_client": true,
|
||||
"user_personal_channel_namespace": "personal",
|
||||
"namespaces": [
|
||||
{
|
||||
"name": "personal",
|
||||
"presence": false,
|
||||
"join_leave": false,
|
||||
"history_size": 10,
|
||||
"history_ttl": "300s",
|
||||
"force_recovery": true,
|
||||
"allow_subscribe_for_client": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
centrifugo:
|
||||
image: centrifugo/centrifugo:v5
|
||||
restart: unless-stopped
|
||||
command: centrifugo -c config.json
|
||||
env_file:
|
||||
- ./.env.centrifugo
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./centrifugo/config.json:/centrifugo/config.json
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
+5
-2
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.20.7",
|
||||
"version": "1.32.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
"build": "vite build",
|
||||
"build:docker": "vite build --config ./vite.config.docker.mts",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
"build:docker": "pnpm run build:packages && vite build --config ./vite.config.docker.mts",
|
||||
"build:migration": "vite build --config ./vite.config-migration.mts",
|
||||
"build:all": "pnpm run build:docker && pnpm run build:migration",
|
||||
"test": "vitest",
|
||||
@@ -54,6 +55,7 @@
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"emailjs": "^4.0.3",
|
||||
"express": "4.21.0",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"passport": "^0.7.0",
|
||||
@@ -61,6 +63,7 @@
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"pg-boss": "^12.14.0",
|
||||
"pino": "^9.4.0",
|
||||
"rotating-file-stream": "^3.2.5",
|
||||
"semver": "^7.6.3",
|
||||
|
||||
+12
-2
@@ -6,6 +6,7 @@ import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
const allow = new Set([
|
||||
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
|
||||
@@ -30,6 +31,7 @@ export default class App {
|
||||
this.extendApp();
|
||||
|
||||
this.initializeMiddlewares();
|
||||
registerAllEventHandlers();
|
||||
this.initializeRoutes();
|
||||
this.app.use(errorHandler);
|
||||
this.app.use(passport.initialize());
|
||||
@@ -63,7 +65,14 @@ export default class App {
|
||||
}));
|
||||
|
||||
this.app.use(helmet());
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.json({
|
||||
verify: (req: any, _res, buf) => {
|
||||
// Store raw body for webhook signature verification github and gitlab integrations
|
||||
if (req.url?.includes('/webhook/')) {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
}
|
||||
|
||||
@@ -74,8 +83,9 @@ export default class App {
|
||||
}
|
||||
|
||||
public listen() {
|
||||
return this.app.listen(this.port, '0.0.0.0', () => {
|
||||
return this.app.listen(this.port, '0.0.0.0', async () => {
|
||||
console.log(`Server is running on port ${this.port}`);
|
||||
await startAllWorkers();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { KanbanManager } from '../tv-modules/kanban/KanbanManager';
|
||||
import { GoalListManager } from '../tv-modules/lists/GoalListManager';
|
||||
import { StartManager } from '../tv-modules/start/StartManager';
|
||||
import { TagsManager } from '../tv-modules/tags/TagsManager';
|
||||
import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsManager';
|
||||
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
|
||||
import { TasksManager } from '../tv-modules/tasks/TasksManager';
|
||||
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
|
||||
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
|
||||
@@ -26,6 +28,8 @@ export class AppUser {
|
||||
public readonly startManager: StartManager;
|
||||
public readonly kanbanManager: KanbanManager;
|
||||
public readonly graphManager: GraphManager;
|
||||
public readonly integrationsManager: IntegrationsManager;
|
||||
public readonly notificationsManager: NotificationsManager;
|
||||
|
||||
constructor(userData?: UserJwtPayload) {
|
||||
this.userData = userData;
|
||||
@@ -40,6 +44,8 @@ export class AppUser {
|
||||
this.startManager = new StartManager(this);
|
||||
this.kanbanManager = new KanbanManager(this);
|
||||
this.graphManager = new GraphManager(this);
|
||||
this.integrationsManager = new IntegrationsManager(this);
|
||||
this.notificationsManager = new NotificationsManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
interface CentrifugoPublishPayload {
|
||||
channel: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CentrifugoClient {
|
||||
private readonly apiUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.CENTRIFUGO_API_URL;
|
||||
const key = process.env.CENTRIFUGO_API_KEY;
|
||||
this.enabled = !!(url && key);
|
||||
this.apiUrl = url || '';
|
||||
this.apiKey = key || '';
|
||||
|
||||
if (!this.enabled) {
|
||||
$logger.warn('[Centrifugo] Not configured — real-time notifications disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async publish(channel: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
if (!this.enabled) return false;
|
||||
|
||||
try {
|
||||
const payload: CentrifugoPublishPayload = { channel, data };
|
||||
const response = await fetch(`${this.apiUrl}/api/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `apikey ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
$logger.error({ status: response.status }, '[Centrifugo] Publish failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
$logger.error({ err }, '[Centrifugo] Publish error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async publishToUser(userId: number, event: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
return this.publish(`personal:#${userId}`, { event, ...data });
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
static generateConnectionToken(userId: number): string {
|
||||
const secret = process.env.CENTRIFUGO_TOKEN_SECRET || process.env.JWT_SIGN || '';
|
||||
return jwt.sign(
|
||||
{ sub: String(userId) },
|
||||
secret,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: CentrifugoClient | null = null;
|
||||
|
||||
export function getCentrifugoClient(): CentrifugoClient {
|
||||
if (!_instance) {
|
||||
_instance = new CentrifugoClient();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Dispatcher {
|
||||
register(): void;
|
||||
registerWorkers(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
'task.created': { task: TasksSchemaTypeForSelect; initiatorId: number };
|
||||
'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 };
|
||||
}
|
||||
|
||||
type EventName = keyof AppEvents;
|
||||
type EventHandler<T extends EventName> = (data: AppEvents[T]) => void | Promise<void>;
|
||||
|
||||
class AppEventBus {
|
||||
private emitter = new EventEmitter();
|
||||
|
||||
on<T extends EventName>(event: T, handler: EventHandler<T>) {
|
||||
this.emitter.on(event, (data: AppEvents[T]) => {
|
||||
try {
|
||||
const result = handler(data);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => {
|
||||
$logger.error(err, `EventBus handler error [${event}]`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
$logger.error(err, `EventBus handler error [${event}]`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit<T extends EventName>(event: T, data: AppEvents[T]) {
|
||||
this.emitter.emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new AppEventBus();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { PgBoss } from 'pg-boss';
|
||||
import { $logger } from '../modules/logget';
|
||||
import { Database } from '../modules/db';
|
||||
|
||||
let boss: PgBoss | null = null;
|
||||
|
||||
export async function startJobQueue(): Promise<PgBoss> {
|
||||
boss = new PgBoss({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: +process.env.DB_PORT!,
|
||||
schema: 'pgboss',
|
||||
});
|
||||
|
||||
boss.on('error', (err) => {
|
||||
$logger.error(err, '[JobQueue] Error');
|
||||
});
|
||||
|
||||
await boss.start();
|
||||
$logger.info('[JobQueue] Started');
|
||||
|
||||
return boss;
|
||||
}
|
||||
|
||||
export function getJobQueue(): PgBoss {
|
||||
if (!boss) {
|
||||
throw new Error('JobQueue not started. Call startJobQueue() first.');
|
||||
}
|
||||
return boss;
|
||||
}
|
||||
|
||||
/** Cancel jobs by singletonKey — finds and deletes matching queued jobs */
|
||||
export async function cancelJobBySingletonKey(queueName: string, singletonKey: string): Promise<void> {
|
||||
if (!boss) return;
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ id: string }>(
|
||||
`SELECT id FROM pgboss.job WHERE name = $1 AND singleton_key = $2 AND state IN ('created', 'retry')`,
|
||||
[queueName, singletonKey],
|
||||
);
|
||||
|
||||
const count = result?.rows?.length ?? 0;
|
||||
if (count === 0) {
|
||||
$logger.info(`[JobQueue] Cancel: no jobs found for key="${singletonKey}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of result!.rows) {
|
||||
await boss.deleteJob(queueName, row.id);
|
||||
$logger.info(`[JobQueue] Deleted job id=${row.id} key="${singletonKey}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { startJobQueue } from './JobQueue';
|
||||
import type { Dispatcher } from './Dispatcher';
|
||||
import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher';
|
||||
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
new WebhooksDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
dispatchers.forEach((d) => d.register());
|
||||
}
|
||||
|
||||
export async function startAllWorkers() {
|
||||
await startJobQueue();
|
||||
for (const d of dispatchers) {
|
||||
await d.registerWorkers();
|
||||
}
|
||||
}
|
||||
@@ -305,5 +305,72 @@
|
||||
"description": [
|
||||
"Validate tag and task belong to the same project on insert into tasks_to_tags"
|
||||
]
|
||||
},
|
||||
"24": {
|
||||
"version": "1.21.0",
|
||||
"name": "Release 1.21.0",
|
||||
"releaseDate": "20260302",
|
||||
"scripts": [
|
||||
"/1.21.0/0.1.21.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added integrations table for GitHub/GitLab integration",
|
||||
"Added integration_task_map table for issue-task mapping"
|
||||
]
|
||||
},
|
||||
"25": {
|
||||
"version": "1.22.0",
|
||||
"name": "Release 1.22.0",
|
||||
"releaseDate": "20260304",
|
||||
"scripts": [
|
||||
"/1.22.0/0.1.22.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added last_synced_at column to integrations for incremental sync"
|
||||
]
|
||||
},
|
||||
"26": {
|
||||
"version": "1.23.0",
|
||||
"name": "Release 1.23.0",
|
||||
"releaseDate": "20260314",
|
||||
"scripts": [
|
||||
"/1.23.0/0.1.23.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added source_url column to tasks for external issue links"
|
||||
]
|
||||
},
|
||||
"27": {
|
||||
"version": "1.24.0",
|
||||
"name": "Release 1.24.0",
|
||||
"releaseDate": "20260315",
|
||||
"scripts": [
|
||||
"/1.24.0/0.1.24.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added reminders, notifications, and push_subscriptions tables"
|
||||
]
|
||||
},
|
||||
"28": {
|
||||
"version": "1.25.0",
|
||||
"name": "Release 1.25.0",
|
||||
"releaseDate": "20260317",
|
||||
"scripts": [
|
||||
"/1.25.0/0.1.25.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added type column to notifications table"
|
||||
]
|
||||
},
|
||||
"29": {
|
||||
"version": "1.26.0",
|
||||
"name": "Release 1.26.0",
|
||||
"releaseDate": "20260320",
|
||||
"scripts": [
|
||||
"/1.26.0/0.1.26.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added notification_preferences table with JSONB settings"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.integrations (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
provider VARCHAR(20) NOT NULL CHECK (provider IN ('github', 'gitlab')),
|
||||
access_token_encrypted TEXT,
|
||||
refresh_token_encrypted TEXT,
|
||||
repo_external_id VARCHAR(255),
|
||||
repo_full_name VARCHAR(255),
|
||||
project_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
webhook_id VARCHAR(255),
|
||||
webhook_secret_encrypted TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.integration_task_map (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
integration_id INTEGER NOT NULL REFERENCES tasks.integrations(id) ON DELETE CASCADE,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
|
||||
issue_number INTEGER NOT NULL,
|
||||
issue_state VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
synced_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(integration_id, issue_number)
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE tasks.integrations ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'integrations_can_manage',
|
||||
'User can manage integrations (connect, disconnect, sync)',
|
||||
2,
|
||||
'{
|
||||
"en": "Manage integrations. User can connect, disconnect, configure and sync integrations",
|
||||
"ru": "Управление интеграциями. Пользователь может подключать, отключать, настраивать и синхронизировать интеграции"
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'integrations_can_view',
|
||||
'User can view integrations list',
|
||||
2,
|
||||
'{
|
||||
"en": "View integrations. User can view the list of connected integrations",
|
||||
"ru": "Просмотр интеграций. Пользователь может просматривать список подключённых интеграций"
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.tasks
|
||||
ADD COLUMN IF NOT EXISTS source_url VARCHAR(500);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Notifications
|
||||
CREATE TABLE IF NOT EXISTS tasks.notifications (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body VARCHAR(1000),
|
||||
read BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||
ON tasks.notifications (user_id, created_at DESC) WHERE NOT read;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE tasks.notifications
|
||||
ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'deadline';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.device_tokens (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(500) NOT NULL,
|
||||
platform VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_device_token UNIQUE (user_id, token)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Convert TIMETZ columns to TIME (without timezone)
|
||||
-- Existing values are converted to UTC automatically by "AT TIME ZONE 'UTC'"
|
||||
ALTER TABLE tasks.tasks
|
||||
ALTER COLUMN start_time TYPE TIME USING start_time AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN end_time TYPE TIME USING end_time AT TIME ZONE 'UTC';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.device_tokens
|
||||
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.webhooks (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
secret_encrypted VARCHAR NOT NULL,
|
||||
events VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
webhook_id INTEGER NOT NULL REFERENCES tasks.webhooks(id) ON DELETE CASCADE,
|
||||
event VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
response_code INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_goal_id ON tasks.webhooks(goal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON tasks.webhook_deliveries(webhook_id);
|
||||
@@ -3,6 +3,9 @@ import CollaborationRoutes from '../tv-modules/collaboration/CollaborationRoutes
|
||||
import CollaborationRolesRoutes from '../tv-modules/collaboration-roles/CollaborationRolesRoutes';
|
||||
import GoalsRoutes from '../tv-modules/goals/GoalsRoutes';
|
||||
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 KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
|
||||
import StartRoutes from '../tv-modules/start/StartRoutes';
|
||||
@@ -23,6 +26,9 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/about': StartRoutes,
|
||||
'/module/kanban': KanbanRoutes,
|
||||
'/module/graph': GraphRoutes,
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -18,6 +18,13 @@ export class GraphRepository {
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async fetchById(id: number): Promise<GraphReturnRelationsType | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
public async fetchAllEdges(goalId: number): Promise<GraphReturnRelationsType[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(GraphRelationsSchema).where(eq(GraphRelationsSchema.goalId, goalId))
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { GraphController } from './GraphControler';
|
||||
import { CanManageGraph } from './middlewares/CanManageGraph';
|
||||
import { CanViewGraph } from './middlewares/CanViewGraph';
|
||||
|
||||
export default class GraphRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly graphController: GraphController;
|
||||
@@ -17,8 +20,8 @@ export default class GraphRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn], this.graphController.addEdge);
|
||||
this.router.get('/:goalId', [IsLoggedIn], this.graphController.fetchAllEdges);
|
||||
this.router.delete('/:id', [IsLoggedIn], this.graphController.deleteEdge);
|
||||
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
|
||||
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
|
||||
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveGoalId } from './resolveGoalId';
|
||||
|
||||
export const CanManageGraph = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (checker.hasPermissions(GoalPermissions.GRAPH_CAN_MANAGE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveGoalId } from './resolveGoalId';
|
||||
|
||||
export const CanViewGraph = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (checker.hasPermissions(GoalPermissions.GRAPH_CAN_VIEW)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Request } from 'express';
|
||||
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
|
||||
*/
|
||||
export async function resolveGoalId(req: Request): Promise<number | null> {
|
||||
// Direct goalId in params (fetchAllEdges)
|
||||
if (req.params.goalId) {
|
||||
const id = Number(req.params.goalId);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// deleteEdge: resolve goalId from edge
|
||||
if (req.params.id) {
|
||||
const edgeId = Number(req.params.id);
|
||||
if (isNaN(edgeId)) return null;
|
||||
const graphRepo = new GraphRepository();
|
||||
const edge = await graphRepo.fetchById(edgeId);
|
||||
return edge?.goalId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError } from '../../utils/api';
|
||||
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 { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
|
||||
|
||||
export default class IntegrationsController {
|
||||
createIntegration = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeAdd(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.create(out).catch(logError);
|
||||
return res.tvJson(result ?? null);
|
||||
};
|
||||
|
||||
deleteIntegration = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeDelete(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.delete(out).catch(logError);
|
||||
return res.tvJson(!!result);
|
||||
};
|
||||
|
||||
toggleIntegration = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeToggle(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.toggle(out).catch(logError);
|
||||
return res.tvJson(result ?? null);
|
||||
};
|
||||
|
||||
fetchIntegrations = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeFetch(req.query);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.fetch(out).catch(logError);
|
||||
return res.tvJson(result ?? []);
|
||||
};
|
||||
|
||||
initiateOAuth = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
return res.status(401).send('token is required');
|
||||
}
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (!userPayload?.userData?.id) {
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const provider = req.params.provider;
|
||||
const projectId = Number(req.query.projectId);
|
||||
if (!projectId || isNaN(projectId)) {
|
||||
return res.status(400).send('projectId is required');
|
||||
}
|
||||
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
|
||||
return res.redirect(url);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Failed to initiate OAuth');
|
||||
}
|
||||
};
|
||||
|
||||
handleOAuthCallback = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = req.params.provider;
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
|
||||
if (!code || !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);
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRepos = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const integrationId = Number(req.query.integrationId);
|
||||
if (!integrationId || isNaN(integrationId)) {
|
||||
return res.status(400).send('integrationId is required');
|
||||
}
|
||||
const repos = await req.appUser.integrationsManager.fetchRepos(integrationId);
|
||||
return res.tvJson(repos);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.tvJson([]);
|
||||
}
|
||||
};
|
||||
|
||||
selectRepo = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeSelectRepo(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.selectRepo(out).catch(logError);
|
||||
return res.tvJson(result ?? null);
|
||||
};
|
||||
|
||||
syncIntegration = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const integrationId = Number(req.body.integrationId);
|
||||
if (!integrationId || isNaN(integrationId)) {
|
||||
return res.status(400).send('integrationId is required');
|
||||
}
|
||||
const synced = await req.appUser.integrationsManager.syncIssues(integrationId);
|
||||
return res.tvJson({ synced });
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.tvJson({ synced: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
handleGitHubWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const signature = req.headers['x-hub-signature-256'] as string;
|
||||
const event = req.headers['x-github-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 || !verifyGitHubWebhookSignature(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) {
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueNumber,
|
||||
'open',
|
||||
issueBody,
|
||||
false,
|
||||
`${GITHUB_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;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).send('Missing token');
|
||||
}
|
||||
|
||||
if (req.body?.object_kind !== 'issue') {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const projectId = String(req.body?.project?.id);
|
||||
if (!projectId) {
|
||||
return res.status(400).send('Missing project');
|
||||
}
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integrations = await repo.fetchAllActiveByRepoExternalId(projectId);
|
||||
if (integrations.length === 0) {
|
||||
return res.status(404).send('Integration not found');
|
||||
}
|
||||
|
||||
// Verify token 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!);
|
||||
if (!verifyGitLabWebhookToken(token, secret)) {
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const attrs = req.body.object_attributes;
|
||||
if (!attrs) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const issueIid = attrs.iid as number;
|
||||
const issueTitle = attrs.title as string;
|
||||
const issueDescription = (attrs.description as string) || null;
|
||||
const action = attrs.action as string;
|
||||
|
||||
for (const integration of integrations) {
|
||||
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueIid);
|
||||
|
||||
if (action === 'open') {
|
||||
if (!mapping) {
|
||||
const repoPath = req.body?.project?.path_with_namespace;
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueIid,
|
||||
'open',
|
||||
issueDescription,
|
||||
false,
|
||||
`${GITLAB_BASE_URL}/${repoPath}/-/issues/${issueIid}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueDescription);
|
||||
}
|
||||
} else if (action === 'close') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, true);
|
||||
await repo.updateMappingState(mapping.id, 'closed');
|
||||
}
|
||||
} else if (action === 'reopen') {
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { encrypt, decrypt } from '../../utils/crypto';
|
||||
|
||||
import { logError } from '../../utils/api';
|
||||
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 { 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';
|
||||
|
||||
export class IntegrationsManager {
|
||||
public readonly repository: IntegrationsRepository;
|
||||
private readonly user: AppUser;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new IntegrationsRepository();
|
||||
}
|
||||
|
||||
async create(data: IntegrationsArgAdd): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
return this.repository.create(data);
|
||||
}
|
||||
|
||||
async delete(data: IntegrationsArgDelete): Promise<boolean> {
|
||||
return this.repository.delete(data);
|
||||
}
|
||||
|
||||
async toggle(data: IntegrationsArgToggle): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await this.repository.toggle(data);
|
||||
if (result && data.isActive) {
|
||||
this.syncIssues(data.id).catch(logError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetch(data: IntegrationsArgFetch): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const projectId = Number(data.projectId);
|
||||
if (isNaN(projectId)) return [];
|
||||
return this.repository.fetchByProjectId(projectId);
|
||||
}
|
||||
|
||||
getOAuthUrl(provider: string, projectId: number, userId: number): string {
|
||||
const state = jwt.sign(
|
||||
{ userId, projectId, provider } as OAuthStatePayload,
|
||||
process.env.JWT_SIGN as string,
|
||||
{ expiresIn: '10m' }
|
||||
);
|
||||
|
||||
if (provider === 'github') {
|
||||
return getGitHubOAuthUrl(state);
|
||||
} else if (provider === 'gitlab') {
|
||||
return getGitLabOAuthUrl(state);
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
|
||||
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
|
||||
|
||||
if (payload.provider !== provider) {
|
||||
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
|
||||
throw new Error('Provider mismatch in state');
|
||||
}
|
||||
|
||||
const userLogin = await this.repository.fetchUserLogin(payload.userId);
|
||||
if (!userLogin) {
|
||||
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
let accessTokenEncrypted: string;
|
||||
let refreshTokenEncrypted: string | null = null;
|
||||
|
||||
if (provider === 'github') {
|
||||
const accessToken = await exchangeGitHubCode(code);
|
||||
accessTokenEncrypted = encrypt(accessToken);
|
||||
} else if (provider === 'gitlab') {
|
||||
const tokens = await exchangeGitLabCode(code);
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = encrypt(tokens.refreshToken);
|
||||
} else {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
await this.repository.createWithToken(
|
||||
provider as 'github' | 'gitlab',
|
||||
payload.projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted,
|
||||
);
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, userLogin };
|
||||
}
|
||||
|
||||
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
|
||||
const integration = await this.repository.fetchById(integrationId);
|
||||
if (!integration || !integration.accessTokenEncrypted) return [];
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) return [];
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
const repos = await fetchGitHubRepos(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,
|
||||
}));
|
||||
} else if (integration.provider === 'gitlab') {
|
||||
const repos = await fetchGitLabRepos(accessToken);
|
||||
return repos.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.path_with_namespace,
|
||||
name: r.name,
|
||||
isPrivate: r.visibility === 'private',
|
||||
description: r.description,
|
||||
url: r.web_url,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async selectRepo(data: IntegrationsArgSelectRepo): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const integration = await this.repository.fetchById(data.integrationId);
|
||||
if (!integration) return false;
|
||||
|
||||
const exists = await this.repository.existsRepoInProject(integration.projectId, data.repoFullName, data.integrationId);
|
||||
if (exists) {
|
||||
$logger.debug({ integrationId: data.integrationId, repoFullName: data.repoFullName, projectId: integration.projectId }, '[integrations] repo already connected to project, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.repository.updateRepo(data);
|
||||
if (result) {
|
||||
$logger.debug({ integrationId: data.integrationId, repoFullName: data.repoFullName }, '[integrations] repo selected, starting sync and webhook registration');
|
||||
this.syncIssues(data.integrationId).catch(logError);
|
||||
this.registerWebhook(data.integrationId).catch(logError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async registerWebhook(integrationId: number): Promise<void> {
|
||||
const integration = await this.repository.fetchById(integrationId);
|
||||
if (!integration || !integration.accessTokenEncrypted || !integration.repoFullName) return;
|
||||
|
||||
const apiUrl = process.env.API_URL;
|
||||
if (!apiUrl) return;
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) {
|
||||
$logger.error({ integrationId, provider: integration.provider }, '[integrations] registerWebhook failed: no access token');
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = randomBytes(32).toString('hex');
|
||||
const webhookUrl = `${apiUrl}/module/integrations/webhook/${integration.provider}`;
|
||||
|
||||
let webhookId: string;
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
const result = await createGitHubWebhook(accessToken, integration.repoFullName, webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.debug({ integrationId, provider: integration.provider, webhookId }, '[integrations] webhook registered');
|
||||
await this.repository.updateWebhook(integrationId, webhookId, encrypt(webhookSecret));
|
||||
}
|
||||
|
||||
async syncIssues(integrationId: number): Promise<number> {
|
||||
const integration = await this.repository.fetchById(integrationId);
|
||||
if (!integration || !integration.accessTokenEncrypted || !integration.repoFullName) return 0;
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) {
|
||||
$logger.error({ integrationId, provider: integration.provider }, '[integrations] syncIssues failed: no access token');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const since = integration.lastSyncedAt?.toISOString();
|
||||
$logger.debug({ integrationId, provider: integration.provider, repo: integration.repoFullName, since: since ?? 'full sync' }, '[integrations] syncIssues start');
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
|
||||
const newItems: NewIssueItem[] = [];
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
const issues = await fetchGitHubIssues(accessToken, 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, `${GITHUB_BASE_URL}/${integration.repoFullName}/issues/${issue.number}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.number,
|
||||
issueState: issue.state === 'open' ? 'open' : 'closed',
|
||||
note: issue.body ?? null,
|
||||
complete: issue.state === 'closed',
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${GITHUB_BASE_URL}/${integration.repoFullName}/issues/${issue.number}`,
|
||||
});
|
||||
}
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const issues = await fetchGitLabIssues(accessToken, Number(integration.repoExternalId), since);
|
||||
for (const issue of issues) {
|
||||
const existing = mappingsByIssueNumber.get(issue.iid);
|
||||
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.description ?? null).catch(logError);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
const isClosed = issue.state === 'closed';
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.iid,
|
||||
issueState: isClosed ? 'closed' : 'open',
|
||||
note: issue.description ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Issues come newest-first from API.
|
||||
// Reverse so oldest is inserted first (lower ID) and newest last (higher ID).
|
||||
// This way list view (ORDER BY id DESC) shows newest first.
|
||||
// Assign kanbanOrder so newest = smallest (appears first in kanban).
|
||||
// Issues come newest-first from API.
|
||||
// Reverse so oldest is inserted first (lower ID) and newest last (higher ID).
|
||||
// List view (ORDER BY id DESC) shows newest first.
|
||||
// Assign kanbanOrder: each next item goes further into minus from current min.
|
||||
// Newest (last in array) gets the smallest value → appears first in kanban.
|
||||
if (newItems.length > 0) {
|
||||
newItems.reverse();
|
||||
const { KANBAN_ORDER_GAP } = TasksRepository;
|
||||
const min = await this.user.tasksManager.repository.fetchTaskWithMinKanbanOrder(integration.projectId, null);
|
||||
for (let i = 0; i < newItems.length; i++) {
|
||||
newItems[i].kanbanOrder = (min ?? 0) - KANBAN_ORDER_GAP * (i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const created = await this.repository.createTasksAndMappingsBatch(newItems);
|
||||
await this.repository.updateLastSyncedAt(integrationId);
|
||||
|
||||
$logger.debug({ integrationId, created, updatedExisting: existingMappings.length, totalIssuesFetched: newItems.length + existingMappings.length }, '[integrations] syncIssues completed');
|
||||
return created;
|
||||
}
|
||||
|
||||
async onTaskCompleteChanged(taskId: number, complete: boolean): Promise<boolean> {
|
||||
const mapping = await this.repository.fetchMappingByTaskId(taskId);
|
||||
if (!mapping) return true;
|
||||
|
||||
const { integration } = mapping;
|
||||
$logger.debug({ taskId, complete, provider: integration.provider, isActive: integration.isActive, issueNumber: mapping.issueNumber, issueState: mapping.issueState }, '[integrations] onTaskCompleteChanged');
|
||||
if (!integration.isActive || !integration.accessTokenEncrypted || !integration.repoFullName) return true;
|
||||
|
||||
const targetState = complete ? 'closed' : 'open';
|
||||
if (mapping.issueState === targetState) {
|
||||
$logger.debug({ taskId, targetState }, '[integrations] onTaskCompleteChanged: state already matches, skipping');
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) {
|
||||
$logger.error({ taskId, integrationId: integration.id, provider: integration.provider }, '[integrations] onTaskCompleteChanged: no access token');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
await updateGitHubIssueState(accessToken, integration.repoFullName, mapping.issueNumber, targetState);
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
await updateGitLabIssueState(
|
||||
accessToken,
|
||||
Number(integration.repoExternalId),
|
||||
mapping.issueNumber,
|
||||
complete ? 'close' : 'reopen',
|
||||
);
|
||||
}
|
||||
|
||||
await this.repository.updateMappingState(mapping.id, targetState);
|
||||
$logger.debug({ taskId, issueNumber: mapping.issueNumber, targetState }, '[integrations] onTaskCompleteChanged: issue state updated');
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
|
||||
if (!integration.accessTokenEncrypted) return null;
|
||||
|
||||
const accessToken = decrypt(integration.accessTokenEncrypted);
|
||||
|
||||
if (integration.provider !== 'gitlab' || !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}` },
|
||||
});
|
||||
return accessToken;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 401) return accessToken;
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing');
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
try {
|
||||
const refreshToken = decrypt(integration.refreshTokenEncrypted);
|
||||
const tokens = 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');
|
||||
return tokens.accessToken;
|
||||
} catch (err) {
|
||||
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
|
||||
import { IntegrationsSchema, IntegrationTaskMapSchema, 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 { TasksRepository } from '../tasks/TasksRepository';
|
||||
|
||||
export class IntegrationsRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: IntegrationsArgAdd): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(IntegrationsSchema).values({
|
||||
provider: data.provider,
|
||||
repoFullName: data.repoFullName,
|
||||
projectId: data.projectId,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(data: IntegrationsArgDelete): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(IntegrationsSchema).where(eq(IntegrationsSchema.id, data.id))
|
||||
);
|
||||
if (!result) return false;
|
||||
return !!(result?.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
async toggle(data: IntegrationsArgToggle): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ isActive: data.isActive, updatedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, data.id))
|
||||
.returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchByProjectId(projectId: number): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(eq(IntegrationsSchema.projectId, projectId))
|
||||
);
|
||||
if (!result) return [];
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<IntegrationsSchemaTypeForSelect | undefined> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(eq(IntegrationsSchema.id, id))
|
||||
);
|
||||
if (!result || result.length === 0) return undefined;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createWithToken(
|
||||
provider: 'github' | 'gitlab',
|
||||
projectId: number,
|
||||
accessTokenEncrypted: string,
|
||||
refreshTokenEncrypted?: string | null,
|
||||
): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(IntegrationsSchema).values({
|
||||
provider,
|
||||
projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted: refreshTokenEncrypted ?? null,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async existsRepoInProject(projectId: number, repoFullName: string, excludeIntegrationId?: number): Promise<boolean> {
|
||||
const conditions = [
|
||||
eq(IntegrationsSchema.projectId, projectId),
|
||||
eq(IntegrationsSchema.repoFullName, repoFullName),
|
||||
];
|
||||
if (excludeIntegrationId) {
|
||||
conditions.push(ne(IntegrationsSchema.id, excludeIntegrationId));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ id: IntegrationsSchema.id }).from(IntegrationsSchema)
|
||||
.where(and(...conditions))
|
||||
);
|
||||
return !!result && result.length > 0;
|
||||
}
|
||||
|
||||
async updateRepo(data: IntegrationsArgSelectRepo): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({
|
||||
repoFullName: data.repoFullName,
|
||||
repoExternalId: data.repoExternalId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(IntegrationsSchema.id, data.integrationId))
|
||||
.returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createTaskAndMapping(
|
||||
goalId: number,
|
||||
description: string,
|
||||
integrationId: number,
|
||||
issueNumber: number,
|
||||
issueState: string,
|
||||
note?: string | null,
|
||||
complete?: boolean,
|
||||
sourceUrl?: string | null,
|
||||
): Promise<IntegrationTaskMapSchemaTypeForSelect | false> {
|
||||
const tasksRepo = new TasksRepository();
|
||||
const kanbanOrder = await tasksRepo.getNextKanbanOrder(goalId);
|
||||
|
||||
const result = await callWithCatch(async () => {
|
||||
const [task] = await this.db.dbDrizzle.insert(TasksSchema).values({
|
||||
goalId,
|
||||
description,
|
||||
complete: complete ?? false,
|
||||
note: note || null,
|
||||
kanbanOrder,
|
||||
sourceUrl: sourceUrl || null,
|
||||
}).returning();
|
||||
const [mapping] = await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values({
|
||||
integrationId,
|
||||
taskId: task.id,
|
||||
issueNumber,
|
||||
issueState,
|
||||
}).returning();
|
||||
return mapping;
|
||||
});
|
||||
if (!result) return false;
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchMappingsByIntegrationId(integrationId: number): Promise<IntegrationTaskMapSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationTaskMapSchema)
|
||||
.where(eq(IntegrationTaskMapSchema.integrationId, integrationId))
|
||||
);
|
||||
if (!result) return [];
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateTokens(integrationId: number, accessTokenEncrypted: string, refreshTokenEncrypted: string | null): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ accessTokenEncrypted, refreshTokenEncrypted, updatedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, integrationId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateWebhook(integrationId: number, webhookId: string, webhookSecretEncrypted: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ webhookId, webhookSecretEncrypted, updatedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, integrationId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchAllActiveByRepoFullName(repoFullName: string): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(IntegrationsSchema.repoFullName, repoFullName),
|
||||
eq(IntegrationsSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async fetchAllActiveByRepoExternalId(repoExternalId: string): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(IntegrationsSchema.repoExternalId, repoExternalId),
|
||||
eq(IntegrationsSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async fetchMappingByIssueNumber(integrationId: number, issueNumber: number): Promise<IntegrationTaskMapSchemaTypeForSelect | undefined> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationTaskMapSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(IntegrationTaskMapSchema.integrationId, integrationId),
|
||||
eq(IntegrationTaskMapSchema.issueNumber, issueNumber),
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!result || result.length === 0) return undefined;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchMappingByTaskId(taskId: number): Promise<(IntegrationTaskMapSchemaTypeForSelect & { integration: IntegrationsSchemaTypeForSelect }) | undefined> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(IntegrationTaskMapSchema)
|
||||
.innerJoin(IntegrationsSchema, eq(IntegrationTaskMapSchema.integrationId, IntegrationsSchema.id))
|
||||
.where(eq(IntegrationTaskMapSchema.taskId, taskId))
|
||||
);
|
||||
if (!result || result.length === 0) return undefined;
|
||||
return {
|
||||
...result[0].integration_task_map,
|
||||
integration: result[0].integrations,
|
||||
};
|
||||
}
|
||||
|
||||
async updateTaskComplete(taskId: number, complete: boolean): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(TasksSchema)
|
||||
.set({ complete })
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateTaskTitleAndNote(taskId: number, description: string, note: string | null): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(TasksSchema)
|
||||
.set({ description, note })
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateTaskSourceUrl(taskId: number, sourceUrl: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(TasksSchema)
|
||||
.set({ sourceUrl })
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async backfillSourceUrls(integrationId: number, urlPrefix: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.execute(sql`
|
||||
UPDATE tasks.tasks t
|
||||
SET source_url = ${urlPrefix} || m.issue_number
|
||||
FROM tasks.integration_task_map m
|
||||
WHERE m.task_id = t.id
|
||||
AND m.integration_id = ${integrationId}
|
||||
AND t.source_url IS NULL
|
||||
`)
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateMappingState(mappingId: number, issueState: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationTaskMapSchema)
|
||||
.set({ issueState, syncedAt: new Date() })
|
||||
.where(eq(IntegrationTaskMapSchema.id, mappingId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async createTasksAndMappingsBatch(
|
||||
items: Array<{ goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null }>,
|
||||
): Promise<number> {
|
||||
if (items.length === 0) return 0;
|
||||
let created = 0;
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||
const batch = items.slice(i, i + BATCH_SIZE);
|
||||
const result = await callWithCatch(async () => {
|
||||
const tasks = await this.db.dbDrizzle.insert(TasksSchema).values(
|
||||
batch.map((item) => ({
|
||||
goalId: item.goalId,
|
||||
description: item.description,
|
||||
complete: item.complete,
|
||||
note: item.note,
|
||||
kanbanOrder: item.kanbanOrder,
|
||||
sourceUrl: item.sourceUrl,
|
||||
})),
|
||||
).returning({ id: TasksSchema.id });
|
||||
|
||||
await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values(
|
||||
tasks.map((task, idx) => ({
|
||||
integrationId: batch[idx].integrationId,
|
||||
taskId: task.id,
|
||||
issueNumber: batch[idx].issueNumber,
|
||||
issueState: batch[idx].issueState,
|
||||
})),
|
||||
);
|
||||
|
||||
return tasks.length;
|
||||
});
|
||||
if (result) created += result;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateLastSyncedAt(integrationId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ lastSyncedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, integrationId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchUserLogin(userId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, userId))
|
||||
);
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].login;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import IntegrationsController from './IntegrationsController';
|
||||
import { CanManageIntegrations } from './middlewares/CanManageIntegrations';
|
||||
import { CanViewIntegrations } from './middlewares/CanViewIntegrations';
|
||||
|
||||
export default class IntegrationsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: IntegrationsController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new IntegrationsController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn, CanViewIntegrations], this.controller.fetchIntegrations);
|
||||
this.router.post('', [IsLoggedIn, CanManageIntegrations], this.controller.createIntegration);
|
||||
this.router.delete('', [IsLoggedIn, CanManageIntegrations], this.controller.deleteIntegration);
|
||||
this.router.patch('/toggle', [IsLoggedIn, CanManageIntegrations], this.controller.toggleIntegration);
|
||||
this.router.patch('/select-repo', [IsLoggedIn, CanManageIntegrations], this.controller.selectRepo);
|
||||
this.router.post('/sync', [IsLoggedIn, CanManageIntegrations], this.controller.syncIntegration);
|
||||
this.router.get('/repos', [IsLoggedIn, CanViewIntegrations], this.controller.fetchRepos);
|
||||
this.router.get('/oauth/:provider', this.controller.initiateOAuth);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveProjectId } from './resolveProjectId';
|
||||
|
||||
export const CanManageIntegrations = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const projectId = await resolveProjectId(req);
|
||||
if (!projectId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(projectId);
|
||||
if (checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveProjectId } from './resolveProjectId';
|
||||
|
||||
export const CanViewIntegrations = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const projectId = await resolveProjectId(req);
|
||||
if (!projectId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(projectId);
|
||||
if (checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_VIEW)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Request } from 'express';
|
||||
import { IntegrationsRepository } from '../IntegrationsRepository';
|
||||
|
||||
/**
|
||||
* Resolves projectId from request.
|
||||
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 repo = new IntegrationsRepository();
|
||||
const integration = await repo.fetchById(integrationId);
|
||||
return integration?.projectId ?? null;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
export const GITHUB_BASE_URL = process.env.GITHUB_BASE_URL || 'https://github.com';
|
||||
const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com';
|
||||
const GITHUB_OAUTH_URL = `${GITHUB_BASE_URL}/login/oauth/authorize`;
|
||||
const GITHUB_TOKEN_URL = `${GITHUB_BASE_URL}/login/oauth/access_token`;
|
||||
|
||||
export type GitHubRepo = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export type GitHubIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
pull_request?: unknown;
|
||||
};
|
||||
|
||||
export function getGitHubOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITHUB_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITHUB_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('GitHub integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'repo',
|
||||
state,
|
||||
});
|
||||
return `${GITHUB_OAUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGitHubCode(code: string): Promise<string> {
|
||||
const res = await axios.post<{ access_token: string; token_type: string }>(
|
||||
GITHUB_TOKEN_URL,
|
||||
{
|
||||
client_id: process.env.GITHUB_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITHUB_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
}
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange GitHub code for token');
|
||||
}
|
||||
return res.data.access_token;
|
||||
}
|
||||
|
||||
export async function fetchGitHubRepos(accessToken: string): Promise<GitHubRepo[]> {
|
||||
const repos: GitHubRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitHubRepo[]>(`${GITHUB_API_URL}/user/repos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
params: {
|
||||
per_page: perPage,
|
||||
page,
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGitHubIssues(accessToken: string, repoFullName: string, since?: string): Promise<GitHubIssue[]> {
|
||||
const issues: GitHubIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitHubIssue[]>(`${GITHUB_API_URL}/repos/${repoFullName}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
per_page: perPage,
|
||||
page,
|
||||
sort: since ? 'updated' : 'created',
|
||||
direction: 'desc',
|
||||
...(since ? { since } : {}),
|
||||
},
|
||||
});
|
||||
// GitHub API returns pull requests as issues too — filter them out
|
||||
const realIssues = res.data.filter((i) => !i.pull_request);
|
||||
issues.push(...realIssues);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGitHubWebhook(
|
||||
accessToken: string,
|
||||
repoFullName: string,
|
||||
webhookUrl: string,
|
||||
secret: string,
|
||||
): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITHUB_API_URL}/repos/${repoFullName}/hooks`,
|
||||
{
|
||||
name: 'web',
|
||||
active: true,
|
||||
events: ['issues'],
|
||||
config: {
|
||||
url: webhookUrl,
|
||||
content_type: 'json',
|
||||
secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGitHubWebhookSignature(rawBody: Buffer, signature: string, secret: string): boolean {
|
||||
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGitHubIssueState(
|
||||
accessToken: string,
|
||||
repoFullName: string,
|
||||
issueNumber: number,
|
||||
state: 'open' | 'closed',
|
||||
): Promise<void> {
|
||||
await axios.patch(
|
||||
`${GITHUB_API_URL}/repos/${repoFullName}/issues/${issueNumber}`,
|
||||
{ state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const GITLAB_BASE_URL = process.env.GITLAB_BASE_URL || 'https://gitlab.com';
|
||||
const GITLAB_API_URL = process.env.GITLAB_API_URL || `${GITLAB_BASE_URL}/api/v4`;
|
||||
const GITLAB_OAUTH_URL = `${GITLAB_BASE_URL}/oauth/authorize`;
|
||||
const GITLAB_TOKEN_URL = `${GITLAB_BASE_URL}/oauth/token`;
|
||||
|
||||
export type GitLabRepo = {
|
||||
id: number;
|
||||
path_with_namespace: string;
|
||||
name: string;
|
||||
visibility: 'private' | 'internal' | 'public';
|
||||
description: string | null;
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
export type GitLabIssue = {
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
state: 'opened' | 'closed';
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
export function getGitLabOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITLAB_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITLAB_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('GitLab integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'api',
|
||||
state,
|
||||
});
|
||||
return `${GITLAB_OAUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGitLabCode(code: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
GITLAB_TOKEN_URL,
|
||||
{
|
||||
client_id: process.env.GITLAB_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITLAB_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: process.env.GITLAB_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange GitLab code for token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshGitLabToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
GITLAB_TOKEN_URL,
|
||||
{
|
||||
client_id: process.env.GITLAB_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITLAB_INTEGRATION_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
redirect_uri: process.env.GITLAB_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to refresh GitLab token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitLabRepos(accessToken: string): Promise<GitLabRepo[]> {
|
||||
const repos: GitLabRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitLabRepo[]>(`${GITLAB_API_URL}/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
membership: true,
|
||||
per_page: perPage,
|
||||
page,
|
||||
order_by: 'updated_at',
|
||||
sort: 'desc',
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGitLabIssues(accessToken: string, projectId: number, updatedAfter?: string): Promise<GitLabIssue[]> {
|
||||
const issues: GitLabIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitLabIssue[]>(`${GITLAB_API_URL}/projects/${projectId}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
per_page: perPage,
|
||||
page,
|
||||
order_by: updatedAfter ? 'updated_at' : 'created_at',
|
||||
sort: 'desc',
|
||||
...(updatedAfter ? { updated_after: updatedAfter } : {}),
|
||||
},
|
||||
});
|
||||
issues.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGitLabWebhook(
|
||||
accessToken: string,
|
||||
projectId: number,
|
||||
webhookUrl: string,
|
||||
secret: string,
|
||||
): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITLAB_API_URL}/projects/${projectId}/hooks`,
|
||||
{
|
||||
url: webhookUrl,
|
||||
issues_events: true,
|
||||
token: secret,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGitLabWebhookToken(headerToken: string, secret: string): boolean {
|
||||
return headerToken === secret;
|
||||
}
|
||||
|
||||
export async function updateGitLabIssueState(
|
||||
accessToken: string,
|
||||
projectId: number,
|
||||
issueIid: number,
|
||||
stateEvent: 'close' | 'reopen',
|
||||
): Promise<void> {
|
||||
await axios.put(
|
||||
`${GITLAB_API_URL}/projects/${projectId}/issues/${issueIid}`,
|
||||
{ state_event: stateEvent },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const IntegrationsArkTypeAdd = type({
|
||||
provider: "'github' | 'gitlab'",
|
||||
repoFullName: 'string',
|
||||
projectId: 'number',
|
||||
});
|
||||
export type IntegrationsArgAdd = typeof IntegrationsArkTypeAdd.infer;
|
||||
|
||||
export const IntegrationsArkTypeDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
export type IntegrationsArgDelete = typeof IntegrationsArkTypeDelete.infer;
|
||||
|
||||
export const IntegrationsArkTypeToggle = type({
|
||||
id: 'number',
|
||||
isActive: 'boolean',
|
||||
});
|
||||
export type IntegrationsArgToggle = typeof IntegrationsArkTypeToggle.infer;
|
||||
|
||||
export const IntegrationsArkTypeFetch = type({
|
||||
projectId: 'string',
|
||||
});
|
||||
export type IntegrationsArgFetch = typeof IntegrationsArkTypeFetch.infer;
|
||||
|
||||
export const IntegrationsArkTypeSelectRepo = type({
|
||||
integrationId: 'number',
|
||||
repoFullName: 'string',
|
||||
repoExternalId: 'string',
|
||||
});
|
||||
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
userId: number;
|
||||
projectId: number;
|
||||
provider: 'github' | 'gitlab';
|
||||
};
|
||||
|
||||
export type RepoItemForClient = {
|
||||
id: number;
|
||||
fullName: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
description: string | null;
|
||||
url: string;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
KanbanArkTypeFetchTasksForColumn,
|
||||
KanbanArkTypeFilters,
|
||||
KanbanArkTypeGetTasksOrderForColumnAndCursor,
|
||||
KanbanArkTypeUpdateTasksOrder,
|
||||
KanbanSchemaAddStatus,
|
||||
@@ -53,7 +54,12 @@ export class KanbanController {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn(data));
|
||||
const filters = KanbanArkTypeFilters(req.query);
|
||||
if (filters instanceof ArkErrors) {
|
||||
return res.status(400).send(filters.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn({ ...data, filters }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DeleteKanbanStatus,
|
||||
type KanbanAddStatus,
|
||||
type KanbanArgFetchTasksForColumn,
|
||||
type KanbanArgFilters,
|
||||
type KanbanArgGetTasksOrderForColumnAndCursor,
|
||||
type KanbanArgUpdateTasksOrder,
|
||||
type KanbanStatusForClient,
|
||||
@@ -44,7 +45,7 @@ export class KanbanManager {
|
||||
return KanbanStatusToClientSchema.parse(status);
|
||||
}
|
||||
|
||||
async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> {
|
||||
async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> {
|
||||
const [tasks, columnVersion] = await Promise.all([
|
||||
this.user.tasksManager.fetchTasksForKanbanColumn(data),
|
||||
this.repository.getColumnVersion(data.goalId, data.columnId)
|
||||
@@ -176,8 +177,7 @@ export class KanbanManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// первая задача в колонке
|
||||
newOrder = KANBAN_GAP;
|
||||
newOrder = await this.user.tasksManager.repository.getNextKanbanOrder(data.goalId);
|
||||
}
|
||||
|
||||
if (newOrder !== null) {
|
||||
|
||||
@@ -54,14 +54,28 @@ export const KanbanArkTypeStatusToClient = type({
|
||||
|
||||
export type KanbanStatusClient = typeof KanbanArkTypeStatusToClient.infer;
|
||||
|
||||
const NullableNumberFromString = type('string|number|null').pipe((v) => (v === 'null' || v === null || v === undefined) ? null : Number(v));
|
||||
|
||||
export const KanbanArkTypeFetchTasksForColumn = type({
|
||||
goalId: NumberFromString,
|
||||
columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
cursor: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
columnId: NullableNumberFromString,
|
||||
cursor: NullableNumberFromString,
|
||||
});
|
||||
|
||||
export type KanbanArgFetchTasksForColumn = typeof KanbanArkTypeFetchTasksForColumn.infer;
|
||||
|
||||
const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) => {
|
||||
if (!v) return [];
|
||||
return v.split(',').map(Number).filter((n) => !isNaN(n));
|
||||
});
|
||||
|
||||
export const KanbanArkTypeFilters = type({
|
||||
'listIds?': NumberArrayFromCommaSeparatedString,
|
||||
'assigneeIds?': NumberArrayFromCommaSeparatedString,
|
||||
});
|
||||
|
||||
export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer;
|
||||
|
||||
export const KanbanArkTypeGetTasksOrderForColumnAndCursor = type({
|
||||
goalId: NumberFromString,
|
||||
columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { eq, and, or, isNull, inArray } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { TasksSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { getNotificationService } from './NotificationService';
|
||||
import { NotificationMessages } from './NotificationMessages';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
|
||||
import { DeadlineScheduler } from './schedulers/DeadlineScheduler';
|
||||
import { NotificationType } from './types';
|
||||
import { parseUtcTime } from './utils';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
|
||||
const CLEANUP_JOB = 'notifications-cleanup';
|
||||
const NOTIFICATIONS_RETENTION_DAYS = 1;
|
||||
|
||||
export class NotificationDispatcher implements Dispatcher {
|
||||
private readonly deadlineScheduler = new DeadlineScheduler();
|
||||
private readonly notificationsRepo = new NotificationsRepository();
|
||||
private readonly deviceTokensRepo = new DeviceTokensRepository();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.onTaskCreated(data));
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
|
||||
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
await this.cleanupWorker();
|
||||
await this.deadlineScheduler.registerWorker();
|
||||
}
|
||||
|
||||
private async cleanupWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(CLEANUP_JOB);
|
||||
await boss.schedule(CLEANUP_JOB, '0 3 * * *');
|
||||
await boss.work(CLEANUP_JOB, async () => {
|
||||
await this.notificationsRepo.deleteOlderThanDays(NOTIFICATIONS_RETENTION_DAYS);
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
}
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
if (data.changes.complete === true) {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasDeadlineChange =
|
||||
data.changes.endDate !== undefined ||
|
||||
data.changes.endTime !== undefined;
|
||||
|
||||
if (!hasDeadlineChange) return;
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Rescheduling deadline for task=${data.task.id}`);
|
||||
|
||||
this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE);
|
||||
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
} else {
|
||||
await this.deadlineScheduler.cancel(data.task.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
if (data.userIds.length === 0) return;
|
||||
|
||||
const db = Database.getInstance();
|
||||
const task = await db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.id, data.taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete))));
|
||||
|
||||
if (!task[0]) return;
|
||||
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const authUserRows = await db.dbDrizzle
|
||||
.select({ userId: authUsers.id })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(inArray(CollaborationUsersSchema.id, data.userIds));
|
||||
|
||||
const recipientIds = authUserRows
|
||||
.map((r) => r.userId)
|
||||
.filter((id) => id !== data.initiatorId);
|
||||
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
await this.handleAssignNotification(data, task[0], recipientIds);
|
||||
await this.handleExpiredDeadlineNotification(data, task[0], recipientIds);
|
||||
}
|
||||
|
||||
private async handleAssignNotification(
|
||||
data: AppEvents['task.assigneesChanged'],
|
||||
task: typeof TasksSchema.$inferSelect,
|
||||
recipientIds: number[],
|
||||
): Promise<void> {
|
||||
const initiatorName = await this.resolveUserName(data.initiatorId);
|
||||
const message = NotificationMessages.assign(task.description, initiatorName);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Assign notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.ASSIGN,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleExpiredDeadlineNotification(
|
||||
data: AppEvents['task.assigneesChanged'],
|
||||
task: typeof TasksSchema.$inferSelect,
|
||||
recipientIds: number[],
|
||||
): Promise<void> {
|
||||
if (!task.endDate) return;
|
||||
|
||||
const isExpired = task.endTime
|
||||
? (parseUtcTime(task.endDate, task.endTime) ?? new Date()) <= new Date()
|
||||
: new Date(`${task.endDate}T00:00:00Z`) <= new Date();
|
||||
|
||||
if (!isExpired) return;
|
||||
|
||||
const tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : 'UTC';
|
||||
const message = NotificationMessages.deadline(task.description, task.endDate, task.endTime, tz);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Expired deadline notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.taskId, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.taskId);
|
||||
}
|
||||
|
||||
private async resolveUserName(userId: number): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
.select({ login: UsersSchema.login })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, userId))
|
||||
.limit(1);
|
||||
return result[0]?.login || 'Someone';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NotificationMessage } from './types';
|
||||
import { parseUtcTime } from './utils';
|
||||
|
||||
export class NotificationMessages {
|
||||
static deadline(description: string | null, endDate: string, endTime: string | null, timezone: string): NotificationMessage {
|
||||
const title = `Task: ${description || 'Task'}`;
|
||||
|
||||
if (endTime) {
|
||||
const deadline = parseUtcTime(endDate, endTime);
|
||||
if (deadline) {
|
||||
const formatted = deadline.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return { title, body: `Deadline: ${formatted}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { title, body: `Deadline: ${endDate}` };
|
||||
}
|
||||
|
||||
static assign(taskDescription: string | null, assignedByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `Assigned to you by ${assignedByName}`,
|
||||
};
|
||||
}
|
||||
|
||||
static mention(taskDescription: string | null, mentionedByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `${mentionedByName} mentioned you`,
|
||||
};
|
||||
}
|
||||
|
||||
static comment(taskDescription: string | null, commentByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `New comment by ${commentByName}`,
|
||||
};
|
||||
}
|
||||
|
||||
static statusChange(taskDescription: string | null, newStatus: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `Status changed to ${newStatus}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { NotificationChannel } from './types';
|
||||
|
||||
export interface NotificationMeta {
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationProvider {
|
||||
readonly channel: NotificationChannel;
|
||||
send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { NotificationMeta, NotificationProvider } from './NotificationProvider';
|
||||
import type { NotificationMessage } from './types';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
import { UserPreferencesRepository } from './repositories/UserPreferencesRepository';
|
||||
import { NotificationChannel, type NotificationType } from './types';
|
||||
import { CentrifugoProvider } from './providers/CentrifugoProvider';
|
||||
import { FCMProvider } from './providers/FCMProvider';
|
||||
|
||||
export class NotificationService {
|
||||
private readonly providers: Map<NotificationChannel, NotificationProvider>;
|
||||
private readonly repo: NotificationsRepository;
|
||||
private readonly preferences: UserPreferencesRepository;
|
||||
|
||||
constructor() {
|
||||
this.repo = new NotificationsRepository();
|
||||
this.preferences = new UserPreferencesRepository();
|
||||
|
||||
const providerList: NotificationProvider[] = [
|
||||
new CentrifugoProvider(),
|
||||
new FCMProvider(),
|
||||
];
|
||||
|
||||
this.providers = new Map();
|
||||
for (const p of providerList) {
|
||||
this.providers.set(p.channel, p);
|
||||
}
|
||||
}
|
||||
|
||||
async notify(
|
||||
userId: number,
|
||||
type: NotificationType,
|
||||
message: NotificationMessage,
|
||||
meta: NotificationMeta,
|
||||
taskId: number | null = null,
|
||||
): Promise<void> {
|
||||
$logger.info(`[NotificationService] notify user=${userId}, type=${type}, title="${message.title}"`);
|
||||
|
||||
const channels = await this.preferences.getEnabledChannels(userId, type, meta.goalId);
|
||||
if (channels.length === 0) {
|
||||
$logger.info(`[NotificationService] All channels disabled for user=${userId}, type=${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await this.repo.create({ userId, taskId, type, title: message.title, body: message.body });
|
||||
if (!notification) {
|
||||
$logger.warn(`[NotificationService] Failed to create notification record for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.info(`[NotificationService] Created notification id=${notification.id}, sending to channels=[${channels.join(',')}]`);
|
||||
|
||||
await Promise.allSettled(
|
||||
channels
|
||||
.map((channel) => this.providers.get(channel))
|
||||
.filter(Boolean)
|
||||
.map((provider) =>
|
||||
provider!.send(userId, notification, meta).catch((err) => {
|
||||
$logger.error(err, `[NotificationService] Provider "${provider!.channel}" failed for user ${userId}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async notifyMany(
|
||||
userIds: number[],
|
||||
type: NotificationType,
|
||||
message: NotificationMessage,
|
||||
meta: NotificationMeta,
|
||||
taskId: number | null = null,
|
||||
): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
userIds.map((userId) => this.notify(userId, type, message, meta, taskId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!_instance) {
|
||||
_instance = new NotificationService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CentrifugoClient } from '../../core/CentrifugoClient';
|
||||
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
|
||||
import { UserPreferencesRepository } from './repositories/UserPreferencesRepository';
|
||||
import { UserPreferences } from './UserPreferences';
|
||||
|
||||
const NotificationArkTypeMarkRead = type({
|
||||
notificationId: 'number',
|
||||
});
|
||||
|
||||
const DeviceTokenArkType = type({
|
||||
token: 'string',
|
||||
platform: "'android' | 'ios'",
|
||||
timezone: 'string',
|
||||
});
|
||||
|
||||
export class NotificationsController {
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : undefined;
|
||||
return res.tvJson(await req.appUser.notificationsManager.fetchByUser(cursor));
|
||||
};
|
||||
|
||||
markRead = async (req: Request, res: Response) => {
|
||||
const out = NotificationArkTypeMarkRead(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
return res.tvJson(await req.appUser.notificationsManager.markRead(out.notificationId));
|
||||
};
|
||||
|
||||
markAllRead = async (_req: Request, res: Response) => {
|
||||
return res.tvJson(await _req.appUser.notificationsManager.markAllRead());
|
||||
};
|
||||
|
||||
registerDevice = async (req: Request, res: Response) => {
|
||||
console.log('[registerDevice] body:', JSON.stringify(req.body));
|
||||
const out = DeviceTokenArkType(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
console.log('[registerDevice] validation error:', out.summary);
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).send('Unauthorized');
|
||||
|
||||
const repo = new DeviceTokensRepository();
|
||||
return res.tvJson(await repo.register(userId, out.token, out.platform, out.timezone));
|
||||
};
|
||||
|
||||
unregisterDevice = async (req: Request, res: Response) => {
|
||||
const { token } = req.body;
|
||||
if (!token || typeof token !== 'string') {
|
||||
return res.status(400).send('token is required');
|
||||
}
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).send('Unauthorized');
|
||||
|
||||
const repo = new DeviceTokensRepository();
|
||||
return res.tvJson(await repo.unregister(userId, token));
|
||||
};
|
||||
|
||||
getPreferences = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(400).send('Bad request');
|
||||
|
||||
const repo = new UserPreferencesRepository();
|
||||
const prefs = await repo.load(userId);
|
||||
return res.tvJson({ settings: prefs.toJSON() });
|
||||
};
|
||||
|
||||
savePreferences = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(400).send('Bad request');
|
||||
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return res.status(400).send('settings is required');
|
||||
}
|
||||
|
||||
const repo = new UserPreferencesRepository();
|
||||
const prefs = new UserPreferences(settings);
|
||||
const result = await repo.save(userId, prefs);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
connectionToken = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const publicUrl = process.env.CENTRIFUGO_PUBLIC_URL;
|
||||
if (!publicUrl) {
|
||||
return res.tvJson({ token: null, url: null });
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
|
||||
const token = CentrifugoClient.generateConnectionToken(userId);
|
||||
return res.tvJson({ token, url });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
|
||||
export class NotificationsManager {
|
||||
private readonly user: AppUser;
|
||||
public readonly repository: NotificationsRepository;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new NotificationsRepository();
|
||||
}
|
||||
|
||||
async fetchByUser(cursor?: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return { notifications: [] };
|
||||
const notifications = await this.repository.fetchByUser(userId, cursor);
|
||||
return { notifications };
|
||||
}
|
||||
|
||||
async markRead(notificationId: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
async markAllRead() {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markAllRead(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { NotificationsController } from './NotificationsController';
|
||||
|
||||
export default class NotificationsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: NotificationsController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new NotificationsController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.patch('/read', [IsLoggedIn], this.controller.markRead);
|
||||
this.router.patch('/read-all', [IsLoggedIn], this.controller.markAllRead);
|
||||
this.router.get('/preferences', [IsLoggedIn], this.controller.getPreferences);
|
||||
this.router.put('/preferences', [IsLoggedIn], this.controller.savePreferences);
|
||||
this.router.get('/connection-token', [IsLoggedIn], this.controller.connectionToken);
|
||||
this.router.post('/device/register', [IsLoggedIn], this.controller.registerDevice);
|
||||
this.router.post('/device/unregister', [IsLoggedIn], this.controller.unregisterDevice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
NotificationChannel,
|
||||
NotificationType,
|
||||
type TypeSettings,
|
||||
type TypeSettingsMap,
|
||||
type DeadlineTypeSettings,
|
||||
type DeadlineIntervals,
|
||||
type SettingsJson,
|
||||
} from './types';
|
||||
|
||||
const ALL_CHANNELS: NotificationChannel[] = [
|
||||
NotificationChannel.PUSH,
|
||||
NotificationChannel.WEBSOCKET
|
||||
];
|
||||
|
||||
/**
|
||||
* Manages user notification preferences.
|
||||
* Opt-out model: undefined = enabled.
|
||||
*
|
||||
* Priority:
|
||||
* 1. projects[goalId][type] — highest
|
||||
* 2. global[type] — fallback
|
||||
* 3. undefined — enabled
|
||||
*/
|
||||
export class UserPreferences {
|
||||
private data: SettingsJson;
|
||||
|
||||
constructor(json: unknown) {
|
||||
this.data = (json && typeof json === 'object' ? json : {}) as SettingsJson;
|
||||
}
|
||||
|
||||
getEnabledChannels(type: NotificationType, goalId?: number): NotificationChannel[] {
|
||||
const resolved = this.resolveTypeSettings(type, goalId);
|
||||
if (!resolved?.channels) return ALL_CHANNELS;
|
||||
return ALL_CHANNELS.filter((ch) => resolved.channels![ch] !== false);
|
||||
}
|
||||
|
||||
isChannelEnabled(type: NotificationType, channel: NotificationChannel, goalId?: number): boolean {
|
||||
const resolved = this.resolveTypeSettings(type, goalId);
|
||||
return resolved?.channels?.[channel] !== false;
|
||||
}
|
||||
|
||||
getDeadlineIntervals(goalId?: number): DeadlineIntervals | undefined {
|
||||
const resolved = this.resolveTypeSettings(NotificationType.DEADLINE, goalId) as DeadlineTypeSettings | undefined;
|
||||
return resolved?.intervals;
|
||||
}
|
||||
|
||||
getEnabledDeadlineIntervals(goalId?: number): number[] {
|
||||
const intervals = this.getDeadlineIntervals(goalId);
|
||||
if (!intervals) return [0]; // default: notify at deadline
|
||||
return Object.entries(intervals)
|
||||
.filter(([, enabled]) => enabled !== false)
|
||||
.map(([minutes]) => Number(minutes));
|
||||
}
|
||||
|
||||
getGlobalTypeSettings<T extends NotificationType>(type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.global?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
getProjectTypeSettings<T extends NotificationType>(goalId: number, type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.projects?.[String(goalId)]?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
setGlobalChannel(type: NotificationType, channel: NotificationChannel, enabled: boolean): void {
|
||||
if (!this.data.global) this.data.global = {};
|
||||
if (!this.data.global[type]) this.data.global[type] = {} as TypeSettingsMap[typeof type];
|
||||
if (!this.data.global[type]!.channels) this.data.global[type]!.channels = {};
|
||||
this.data.global[type]!.channels![channel] = enabled;
|
||||
}
|
||||
|
||||
setDeadlineIntervals(intervals: DeadlineIntervals, goalId?: number): void {
|
||||
if (goalId !== undefined) {
|
||||
const key = String(goalId);
|
||||
if (!this.data.projects) this.data.projects = {};
|
||||
if (!this.data.projects[key]) this.data.projects[key] = {};
|
||||
if (!this.data.projects[key][NotificationType.DEADLINE]) this.data.projects[key][NotificationType.DEADLINE] = {};
|
||||
(this.data.projects[key][NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals;
|
||||
} else {
|
||||
if (!this.data.global) this.data.global = {};
|
||||
if (!this.data.global[NotificationType.DEADLINE]) this.data.global[NotificationType.DEADLINE] = {};
|
||||
(this.data.global[NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals;
|
||||
}
|
||||
}
|
||||
|
||||
setProjectChannel(goalId: number, type: NotificationType, channel: NotificationChannel, enabled: boolean): void {
|
||||
const key = String(goalId);
|
||||
if (!this.data.projects) this.data.projects = {};
|
||||
if (!this.data.projects[key]) this.data.projects[key] = {};
|
||||
if (!this.data.projects[key][type]) this.data.projects[key][type] = {} as TypeSettingsMap[typeof type];
|
||||
if (!this.data.projects[key][type]!.channels) this.data.projects[key][type]!.channels = {};
|
||||
this.data.projects[key][type]!.channels![channel] = enabled;
|
||||
}
|
||||
|
||||
removeProjectSettings(goalId: number): void {
|
||||
delete this.data.projects?.[String(goalId)];
|
||||
}
|
||||
|
||||
toJSON(): SettingsJson {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
private resolveTypeSettings(type: NotificationType, goalId?: number): TypeSettings | undefined {
|
||||
const globalSettings = this.data.global?.[type];
|
||||
|
||||
if (goalId === undefined) return globalSettings;
|
||||
|
||||
const projectSettings = this.data.projects?.[String(goalId)]?.[type];
|
||||
if (!projectSettings) return globalSettings;
|
||||
if (!globalSettings) return projectSettings;
|
||||
|
||||
return {
|
||||
channels: { ...globalSettings.channels, ...projectSettings.channels },
|
||||
...('intervals' in globalSettings || 'intervals' in projectSettings
|
||||
? {
|
||||
intervals: {
|
||||
...(globalSettings as DeadlineTypeSettings).intervals,
|
||||
...(projectSettings as DeadlineTypeSettings).intervals,
|
||||
}
|
||||
}
|
||||
: {}
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { getCentrifugoClient } from '../../../core/CentrifugoClient';
|
||||
import type { NotificationMeta, NotificationProvider } from '../NotificationProvider';
|
||||
import { NotificationChannel } from '../types';
|
||||
|
||||
export class CentrifugoProvider implements NotificationProvider {
|
||||
readonly channel = NotificationChannel.WEBSOCKET;
|
||||
|
||||
async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void> {
|
||||
await getCentrifugoClient().publishToUser(userId, 'notification', {
|
||||
notification,
|
||||
goalId: meta.goalId,
|
||||
goalListId: meta.goalListId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import admin from 'firebase-admin';
|
||||
import { getMessaging } from 'firebase-admin/messaging';
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { NotificationMeta, NotificationProvider } from '../NotificationProvider';
|
||||
import { NotificationChannel } from '../types';
|
||||
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
export class FCMProvider implements NotificationProvider {
|
||||
readonly channel = NotificationChannel.PUSH;
|
||||
|
||||
private static initialized = false;
|
||||
private static messaging: admin.messaging.Messaging | null = null;
|
||||
|
||||
private readonly repo = new DeviceTokensRepository();
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.enabled = FCMProvider.init();
|
||||
}
|
||||
|
||||
private static init(): boolean {
|
||||
if (FCMProvider.initialized) return true;
|
||||
|
||||
const credentialsPath = process.env.FIREBASE_CREDENTIALS_PATH;
|
||||
if (!credentialsPath) {
|
||||
$logger.warn('[FCM] FIREBASE_CREDENTIALS_PATH not configured — push notifications disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(credentialsPath),
|
||||
});
|
||||
FCMProvider.messaging = getMessaging();
|
||||
FCMProvider.messaging.enableLegacyHttpTransport();
|
||||
FCMProvider.initialized = true;
|
||||
$logger.info('[FCM] Firebase initialized with legacy HTTP/1.1 transport');
|
||||
return true;
|
||||
} catch (err) {
|
||||
$logger.error(err, '[FCM] Failed to initialize Firebase');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void> {
|
||||
if (!this.enabled || !FCMProvider.messaging) return;
|
||||
|
||||
const tokens = await this.repo.getByUserId(userId);
|
||||
$logger.info(`[FCM] User ${userId}: found ${tokens.length} device token(s)`);
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
const message: admin.messaging.MulticastMessage = {
|
||||
tokens: tokens.map((t) => t.token),
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body || undefined,
|
||||
},
|
||||
data: {
|
||||
type: notification.type,
|
||||
taskId: notification.taskId ? String(notification.taskId) : '',
|
||||
goalId: String(meta.goalId),
|
||||
goalListId: meta.goalListId ? String(meta.goalListId) : '',
|
||||
notificationId: String(notification.id),
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
$logger.info(`[FCM] Sending to ${tokens.length} token(s) for user ${userId}, title="${notification.title}"`);
|
||||
const response = await FCMProvider.messaging.sendEachForMulticast(message);
|
||||
$logger.info(`[FCM] Result: success=${response.successCount}, failure=${response.failureCount}`);
|
||||
|
||||
if (response.failureCount > 0) {
|
||||
const invalidTokens: string[] = [];
|
||||
response.responses.forEach((resp, idx) => {
|
||||
if (!resp.success) {
|
||||
const code = resp.error?.code;
|
||||
if (code === 'messaging/invalid-registration-token' || code === 'messaging/registration-token-not-registered') {
|
||||
invalidTokens.push(tokens[idx].token);
|
||||
} else {
|
||||
$logger.error(resp.error, '[FCM] Failed to send to token');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of invalidTokens) {
|
||||
await this.repo.deleteByToken(token);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
$logger.error(err, `[FCM] Failed to send multicast for user ${userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { DeviceTokensSchema, type DeviceTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
export class DeviceTokensRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async register(userId: number, token: string, platform: string, timezone: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(DeviceTokensSchema).values({
|
||||
userId,
|
||||
token,
|
||||
platform,
|
||||
timezone,
|
||||
}).onConflictDoUpdate({
|
||||
target: [DeviceTokensSchema.userId, DeviceTokensSchema.token],
|
||||
set: { timezone },
|
||||
})
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async unregister(userId: number, token: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(and(
|
||||
eq(DeviceTokensSchema.userId, userId),
|
||||
eq(DeviceTokensSchema.token, token),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getByUserId(userId: number): Promise<DeviceTokensSchemaTypeForSelect[]> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select()
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
) || [];
|
||||
}
|
||||
|
||||
async getTimezoneByUserId(userId: number): Promise<string> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ timezone: DeviceTokensSchema.timezone })
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0]?.timezone || 'UTC';
|
||||
}
|
||||
|
||||
async deleteByToken(token: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.token, token))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq, desc, lt, sql } from 'drizzle-orm';
|
||||
import { NotificationsSchema, TasksSchema, type NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
export class NotificationsRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { userId: number; taskId: number | null; type: string; title: string; body: string | null }): Promise<NotificationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(NotificationsSchema).values({
|
||||
userId: data.userId,
|
||||
taskId: data.taskId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchByUser(userId: number, cursor?: number) {
|
||||
const limit = 30;
|
||||
const conditions = [eq(NotificationsSchema.userId, userId)];
|
||||
if (cursor) {
|
||||
conditions.push(lt(NotificationsSchema.id, cursor));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({
|
||||
id: NotificationsSchema.id,
|
||||
userId: NotificationsSchema.userId,
|
||||
taskId: NotificationsSchema.taskId,
|
||||
type: NotificationsSchema.type,
|
||||
title: NotificationsSchema.title,
|
||||
body: NotificationsSchema.body,
|
||||
read: NotificationsSchema.read,
|
||||
createdAt: NotificationsSchema.createdAt,
|
||||
goalId: TasksSchema.goalId,
|
||||
goalListId: TasksSchema.goalListId,
|
||||
})
|
||||
.from(NotificationsSchema)
|
||||
.leftJoin(TasksSchema, eq(NotificationsSchema.taskId, TasksSchema.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(NotificationsSchema.id))
|
||||
.limit(limit)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async markRead(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.id, notificationId),
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async deleteByTaskAndType(taskId: number, type: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(NotificationsSchema)
|
||||
.where(and(
|
||||
eq(NotificationsSchema.taskId, taskId),
|
||||
eq(NotificationsSchema.type, type),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async deleteOlderThanDays(days: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(NotificationsSchema)
|
||||
.where(lt(NotificationsSchema.createdAt, sql`NOW() - INTERVAL '${sql.raw(String(days))} days'`))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async markAllRead(userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
eq(NotificationsSchema.read, false),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NotificationPreferencesSchema } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
import { UserPreferences } from '../UserPreferences';
|
||||
import type { NotificationChannel, NotificationType } from '../types';
|
||||
|
||||
export class UserPreferencesRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async load(userId: number): Promise<UserPreferences> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ settings: NotificationPreferencesSchema.settings })
|
||||
.from(NotificationPreferencesSchema)
|
||||
.where(eq(NotificationPreferencesSchema.userId, userId))
|
||||
.limit(1)
|
||||
);
|
||||
return new UserPreferences(result?.[0]?.settings);
|
||||
}
|
||||
|
||||
async save(userId: number, preferences: UserPreferences): Promise<boolean> {
|
||||
const settings = preferences.toJSON();
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(NotificationPreferencesSchema)
|
||||
.values({ userId, settings })
|
||||
.onConflictDoUpdate({
|
||||
target: [NotificationPreferencesSchema.userId],
|
||||
set: { settings },
|
||||
})
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getEnabledChannels(userId: number, type: NotificationType, goalId?: number): Promise<NotificationChannel[]> {
|
||||
const prefs = await this.load(userId);
|
||||
return prefs.getEnabledChannels(type, goalId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { eq, and, or, isNull } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { TasksSchema, TasksAssigneeSchema, GoalsSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { getJobQueue, cancelJobBySingletonKey } from '../../../core/JobQueue';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { getNotificationService } from '../NotificationService';
|
||||
import { NotificationMessages } from '../NotificationMessages';
|
||||
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
|
||||
import { NotificationType, type DeadlineJobData, type TaskWithDeadline } from '../types';
|
||||
import { parseUtcTime } from '../utils';
|
||||
|
||||
const DEADLINE_JOB = 'deadline-notification';
|
||||
|
||||
export class DeadlineScheduler {
|
||||
private readonly deviceTokensRepo = new DeviceTokensRepository();
|
||||
|
||||
async schedule(task: TaskWithDeadline, initiatorId?: number): Promise<void> {
|
||||
if (!task.endDate) return;
|
||||
|
||||
let startAfter: Date | undefined;
|
||||
|
||||
if (task.endTime) {
|
||||
const deadline = parseUtcTime(task.endDate, task.endTime);
|
||||
if (!deadline) return;
|
||||
startAfter = deadline > new Date() ? deadline : undefined;
|
||||
} else {
|
||||
const deadlineDay = new Date(`${task.endDate}T00:00:00Z`);
|
||||
startAfter = deadlineDay > new Date() ? deadlineDay : undefined;
|
||||
}
|
||||
|
||||
const data: DeadlineJobData = {
|
||||
taskId: task.id,
|
||||
description: task.description ?? '',
|
||||
goalId: task.goalId,
|
||||
goalListId: task.goalListId,
|
||||
endDate: task.endDate,
|
||||
endTime: task.endTime,
|
||||
initiatorId: initiatorId ?? null,
|
||||
immediate: !startAfter,
|
||||
};
|
||||
|
||||
$logger.info(`[DeadlineScheduler] Scheduling task=${task.id} at=${startAfter?.toISOString() ?? 'immediate'}`);
|
||||
|
||||
await getJobQueue().send(DEADLINE_JOB, data, {
|
||||
startAfter,
|
||||
singletonKey: this.singletonKey(task.id),
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(taskId: number): Promise<void> {
|
||||
await cancelJobBySingletonKey(DEADLINE_JOB, this.singletonKey(taskId));
|
||||
}
|
||||
|
||||
async registerWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
const db = Database.getInstance();
|
||||
|
||||
await boss.createQueue(DEADLINE_JOB);
|
||||
|
||||
await boss.work<DeadlineJobData>(DEADLINE_JOB, async ([job]) => {
|
||||
const { taskId, description, goalId, goalListId, endDate, endTime, initiatorId, immediate } = job.data;
|
||||
|
||||
if (!taskId) return;
|
||||
$logger.info(`[DeadlineScheduler] Worker: job=${job.id} task=${taskId}`);
|
||||
|
||||
const task = await db.dbDrizzle
|
||||
.select({ owner: TasksSchema.owner, endDate: TasksSchema.endDate, endTime: TasksSchema.endTime })
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.id, taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete))));
|
||||
|
||||
if (task.length === 0) {
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: not found or completed`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (task[0].endDate !== endDate || task[0].endTime !== endTime) {
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: stale job, deadline changed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientIds = await this.resolveRecipients(db, taskId, goalId, task[0].owner);
|
||||
if (!recipientIds || recipientIds.length === 0) {
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: no recipients`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (immediate && initiatorId) {
|
||||
const idx = recipientIds.indexOf(initiatorId);
|
||||
if (idx !== -1) recipientIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: sending to [${recipientIds.join(',')}]`);
|
||||
|
||||
const tz = task[0].owner ? await this.deviceTokensRepo.getTimezoneByUserId(task[0].owner) : 'UTC';
|
||||
const message = NotificationMessages.deadline(description, endDate, endTime, tz);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId, goalListId },
|
||||
taskId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private singletonKey(taskId: number): string {
|
||||
return `deadline-${taskId}`;
|
||||
}
|
||||
|
||||
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<number[] | null> {
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
|
||||
try {
|
||||
const [assignees, goal] = await Promise.all([
|
||||
db.dbDrizzle
|
||||
.select({ userId: authUsers.id })
|
||||
.from(TasksAssigneeSchema)
|
||||
.innerJoin(CollaborationUsersSchema, eq(TasksAssigneeSchema.collabUserId, CollaborationUsersSchema.id))
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(eq(TasksAssigneeSchema.taskId, taskId)),
|
||||
db.dbDrizzle
|
||||
.select({ owner: GoalsSchema.owner })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, goalId)),
|
||||
]);
|
||||
|
||||
const ids = new Set<number>();
|
||||
if (taskOwner) ids.add(taskOwner);
|
||||
assignees.forEach((r) => ids.add(r.userId));
|
||||
if (goal[0]) ids.add(goal[0].owner);
|
||||
|
||||
return [...ids];
|
||||
} catch (err) {
|
||||
$logger.error(err, '[DeadlineScheduler] Failed to resolve recipients');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export enum NotificationType {
|
||||
DEADLINE = 'deadline',
|
||||
ASSIGN = 'assign',
|
||||
MENTION = 'mention',
|
||||
COMMENT = 'comment',
|
||||
STATUS_CHANGE = 'status_change',
|
||||
}
|
||||
|
||||
export enum NotificationChannel {
|
||||
PUSH = 'push',
|
||||
WEBSOCKET = 'websocket',
|
||||
EMAIL = 'email',
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base settings — only channels (for instant notification types)
|
||||
*/
|
||||
export interface BaseTypeSettings {
|
||||
channels?: Partial<Record<NotificationChannel, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed intervals in minutes before deadline.
|
||||
* Key = minutes, value = enabled. undefined = enabled (opt-out)
|
||||
*/
|
||||
export interface DeadlineIntervals {
|
||||
0?: boolean; // at deadline
|
||||
15?: boolean; // 15 min before
|
||||
30?: boolean; // 30 min before
|
||||
60?: boolean; // 1 hour before
|
||||
1440?: boolean; // 1 day before
|
||||
}
|
||||
|
||||
/**
|
||||
* Deadline has intervals (minutes before deadline to notify)
|
||||
*/
|
||||
export interface DeadlineTypeSettings extends BaseTypeSettings {
|
||||
intervals?: DeadlineIntervals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant types — only channels, no intervals
|
||||
*/
|
||||
export type InstantTypeSettings = BaseTypeSettings;
|
||||
|
||||
/**
|
||||
* Maps each notification type to its allowed settings shape
|
||||
*/
|
||||
export interface TypeSettingsMap {
|
||||
[NotificationType.DEADLINE]: DeadlineTypeSettings;
|
||||
[NotificationType.ASSIGN]: InstantTypeSettings;
|
||||
[NotificationType.MENTION]: InstantTypeSettings;
|
||||
[NotificationType.COMMENT]: InstantTypeSettings;
|
||||
[NotificationType.STATUS_CHANGE]: InstantTypeSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all possible type settings (for generic use)
|
||||
*/
|
||||
export type TypeSettings = DeadlineTypeSettings | InstantTypeSettings;
|
||||
|
||||
/**
|
||||
* @example
|
||||
* {
|
||||
* "global": {
|
||||
* "deadline": {
|
||||
* "channels": { "push": true, "websocket": true, "email": false },
|
||||
* "intervals": { "0": true, "15": true, "60": true, "1440": false }
|
||||
* },
|
||||
* "assign": {
|
||||
* "channels": { "push": true, "websocket": true }
|
||||
* },
|
||||
* "mention": {
|
||||
* "channels": { "push": false }
|
||||
* }
|
||||
* },
|
||||
* "projects": {
|
||||
* "42": {
|
||||
* "deadline": {
|
||||
* "channels": { "push": false },
|
||||
* "intervals": { "0": true, "30": true }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Result for user with these settings:
|
||||
* - deadline globally: push + websocket, remind at 0/15/60 min before (1440 disabled)
|
||||
* - deadline in project 42: websocket only (push overridden), remind at 0/30 min before
|
||||
* - assign globally: push + websocket
|
||||
* - mention globally: websocket only (push explicitly disabled)
|
||||
* - comment: no settings → all channels enabled (opt-out)
|
||||
*/
|
||||
export interface SettingsJson {
|
||||
global?: { [K in NotificationType]?: TypeSettingsMap[K] };
|
||||
projects?: Record<string, { [K in NotificationType]?: TypeSettingsMap[K] }>;
|
||||
}
|
||||
|
||||
export interface DeadlineJobData {
|
||||
taskId: number;
|
||||
description: string;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
endDate: string;
|
||||
endTime: string | null;
|
||||
initiatorId: number | null;
|
||||
immediate: boolean;
|
||||
}
|
||||
|
||||
export interface TaskWithDeadline {
|
||||
id: number;
|
||||
description: string | null;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
owner: number | null;
|
||||
endDate: string | null;
|
||||
endTime: string | null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Parse UTC time string (HH:mm:ss) with date into a Date object
|
||||
*/
|
||||
export function parseUtcTime(dateStr: string, timeStr: string): Date | null {
|
||||
const match = timeStr.match(/^(\d{2}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
return new Date(`${dateStr}T${match[1]}:${match[2]}:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a local hour (e.g. 9 for 09:00) in a given IANA timezone
|
||||
* to a UTC Date for the specified date string (YYYY-MM-DD)
|
||||
*/
|
||||
export function localHourToUtc(dateStr: string, hour: number, timezone: string): Date {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const utcMidnight = new Date(`${dateStr}T00:00:00Z`);
|
||||
const parts = formatter.formatToParts(utcMidnight);
|
||||
const tzHour = Number(parts.find(p => p.type === 'hour')?.value ?? 0);
|
||||
const tzDay = Number(parts.find(p => p.type === 'day')?.value ?? 0);
|
||||
const utcDay = utcMidnight.getUTCDate();
|
||||
|
||||
let offsetHours = tzHour - utcMidnight.getUTCHours();
|
||||
if (tzDay > utcDay) offsetHours += 24;
|
||||
else if (tzDay < utcDay) offsetHours -= 24;
|
||||
|
||||
const result = new Date(`${dateStr}T00:00:00Z`);
|
||||
result.setUTCHours(hour - offsetHours, 0, 0, 0);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = new Date(`${dateStr}T00:00:00Z`);
|
||||
fallback.setUTCHours(hour, 0, 0, 0);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +186,9 @@ export class TasksController {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const subtasks = await req.appUser.tasksManager.fetchSubtasks(args.data);
|
||||
// const subtasks = await req.appUser.tasksManager.fetchSubtasks(args.data);
|
||||
|
||||
return res.tvJson(subtasks);
|
||||
return res.tvJson([]);
|
||||
};
|
||||
|
||||
/** @deprecated */
|
||||
@@ -343,13 +343,13 @@ export class TasksController {
|
||||
return res.status(400).send(args.summary);
|
||||
}
|
||||
|
||||
const task = await req.appUser.tasksManager.updateTask(args);
|
||||
const result = await req.appUser.tasksManager.updateTask(args);
|
||||
if (!result) return res.tvJson(null);
|
||||
|
||||
return res.tvJson(task);
|
||||
return res.tvJson({ ...result.task, syncFailed: result.syncFailed });
|
||||
};
|
||||
|
||||
fetchTasksNew = async (req: Request, res: Response) => {
|
||||
// debugger;
|
||||
const out = TaskArkTypeFetchTasksNew(req.query);
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import type {
|
||||
AddTaskArg,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
type TaskForClientNew,
|
||||
type TasksArgToggleTaskUsers,
|
||||
} from './tasks.server.types';
|
||||
import type { KanbanArgFetchTasksForColumn } from '../kanban/types';
|
||||
import type { KanbanArgFetchTasksForColumn, KanbanArgFilters } from '../kanban/types';
|
||||
|
||||
type TaskFieldPermissionKey = keyof typeof TaskFieldPermissionsForEditOrCreation & keyof TasksSchemaTypeForSelect;
|
||||
|
||||
@@ -80,7 +81,7 @@ export class TasksManager {
|
||||
|
||||
const tagsMap: Record<number, number[]> = {};
|
||||
const ids = tasks.map((t) => t.id);
|
||||
|
||||
|
||||
const tags = await this.repository.fetchTagsForTasks(ids);
|
||||
|
||||
if (tags) {
|
||||
@@ -115,7 +116,7 @@ export class TasksManager {
|
||||
|
||||
const tagsMap: Record<number, number[]> = {};
|
||||
const ids = tasks.map((t) => t.id);
|
||||
|
||||
|
||||
const tags = await this.repository.fetchTagsForTasks(ids);
|
||||
|
||||
if (tags) {
|
||||
@@ -156,6 +157,9 @@ export class TasksManager {
|
||||
|
||||
if (!task) return false;
|
||||
|
||||
// Sync task completion state to linked GitHub/GitLab issue
|
||||
this.user.integrationsManager.onTaskCompleteChanged(arg.taskId, arg.complete).catch(() => { });
|
||||
|
||||
return new TaskItemForClient(task);
|
||||
}
|
||||
|
||||
@@ -259,13 +263,36 @@ export class TasksManager {
|
||||
return await this.repository.updateTransactionType(data);
|
||||
}
|
||||
|
||||
async updateTask(data: TaskArgUpdate): Promise<TaskForClientNew | null> {
|
||||
async updateTask(data: TaskArgUpdate): Promise<{ task: TaskForClientNew; syncFailed?: boolean } | null> {
|
||||
if (data.statusId !== undefined) {
|
||||
const currentTask = await this.repository.fetchTaskByIdNew(data.id);
|
||||
if (currentTask && currentTask.statusId !== data.statusId) {
|
||||
data.kanbanOrder = await this.repository.getNextKanbanOrder(currentTask.goalId);
|
||||
}
|
||||
}
|
||||
|
||||
const task = await this.repository.updateTask(data);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let syncFailed = false;
|
||||
if (data.complete !== undefined) {
|
||||
const synced = await this.user.integrationsManager.onTaskCompleteChanged(data.id, data.complete).catch(() => false);
|
||||
if (!synced) syncFailed = true;
|
||||
}
|
||||
|
||||
const { id, ...changes } = data;
|
||||
eventBus.emit('task.updated', {
|
||||
task,
|
||||
changes,
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
|
||||
const tasks = await this.extendTasksWithTagsAndAssignees([task]);
|
||||
return tasks[0] ?? null;
|
||||
const result = tasks[0] ?? null;
|
||||
if (!result) return null;
|
||||
return { task: result, syncFailed: syncFailed || undefined };
|
||||
}
|
||||
|
||||
async fetchTasksNew(data: TaskArgFetchTasksNew) {
|
||||
@@ -372,25 +399,43 @@ export class TasksManager {
|
||||
let newData = { ...data };
|
||||
|
||||
if (data.kanbanOrder === null || data.kanbanOrder === undefined) {
|
||||
const minKanbanOrder = await this.repository.fetchTaskWithMinKanbanOrder(data.goalId, data.statusId ?? null);
|
||||
const GAP = 16384;
|
||||
newData.kanbanOrder = (minKanbanOrder ?? 0) - GAP;
|
||||
newData.kanbanOrder = await this.repository.getNextKanbanOrder(data.goalId, data.statusId ?? null);
|
||||
}
|
||||
|
||||
const task = await this.repository.addTaskNew(newData);
|
||||
return await this.extendTasksWithTagsAndAssignees(task, true);
|
||||
const result = await this.extendTasksWithTagsAndAssignees(task, true);
|
||||
|
||||
if (task[0]) {
|
||||
eventBus.emit('task.created', {
|
||||
task: task[0],
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteTaskNew(data: TaskArgDelete) {
|
||||
return await this.repository.deleteTaskNew(data);
|
||||
const task = await this.repository.fetchTaskByIdNew(data.taskId);
|
||||
const result = await this.repository.deleteTaskNew(data);
|
||||
if (result) {
|
||||
eventBus.emit('task.deleted', { taskId: data.taskId, goalId: task?.goalId ?? 0, initiatorId: this.user.getUserData()?.id as number });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async toggleTaskUsers(data: TasksArgToggleTaskUsers) {
|
||||
return await this.repository.toggleTaskUsers(data);
|
||||
const result = await this.repository.toggleTaskUsers(data);
|
||||
eventBus.emit('task.assigneesChanged', {
|
||||
taskId: data.taskId,
|
||||
userIds: data.userIds,
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> {
|
||||
const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor);
|
||||
async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> {
|
||||
const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor, data.filters);
|
||||
if (!tasks || tasks.length === 0) return { tasks: [], nextCursor: null };
|
||||
return { tasks: await this.extendTasksWithTagsAndAssignees(tasks), nextCursor: tasks[tasks.length - 1].kanbanOrder };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
TaskArgUpdate,
|
||||
TasksArgToggleTaskUsers,
|
||||
} from './tasks.server.types';
|
||||
import type { KanbanArgFilters } from '../kanban/types';
|
||||
|
||||
export class TasksRepository {
|
||||
private readonly db: Database;
|
||||
@@ -643,7 +644,7 @@ export class TasksRepository {
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null): Promise<TasksSchemaTypeForSelect[]> {
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null, filters?: KanbanArgFilters): Promise<TasksSchemaTypeForSelect[]> {
|
||||
const conditions = [
|
||||
eq(TasksSchema.goalId, goalId),
|
||||
columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId),
|
||||
@@ -653,6 +654,24 @@ export class TasksRepository {
|
||||
if (cursor !== null) {
|
||||
conditions.push(gt(TasksSchema.kanbanOrder, cursor));
|
||||
}
|
||||
if (filters?.listIds && filters.listIds.length > 0) {
|
||||
conditions.push(inArray(TasksSchema.goalListId, filters.listIds));
|
||||
}
|
||||
if (filters?.assigneeIds && filters.assigneeIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select({ one: sql`1` })
|
||||
.from(TasksAssigneeSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(TasksAssigneeSchema.taskId, TasksSchema.id),
|
||||
inArray(TasksAssigneeSchema.collabUserId, filters.assigneeIds)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(and(...conditions)).orderBy(asc(TasksSchema.kanbanOrder)).limit(20)
|
||||
@@ -660,14 +679,23 @@ export class TasksRepository {
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchTaskWithMinKanbanOrder(goalId: number, columnId: number | null): Promise<number | null> {
|
||||
async fetchTaskWithMinKanbanOrder(goalId: number, columnId?: number | null): Promise<number | null> {
|
||||
const conditions = [
|
||||
eq(TasksSchema.goalId, goalId),
|
||||
columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId),
|
||||
];
|
||||
if (columnId !== undefined) {
|
||||
conditions.push(columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId));
|
||||
}
|
||||
const result = await callWithCatch(() => this.db.dbDrizzle.select({
|
||||
minKanbanOrder: sql<number>`MIN(kanban_order)`
|
||||
}).from(TasksSchema).where(and(...conditions)));
|
||||
return result?.[0]?.minKanbanOrder ?? null;
|
||||
}
|
||||
|
||||
static readonly KANBAN_ORDER_GAP = 16384;
|
||||
|
||||
async getNextKanbanOrder(goalId: number, columnId?: number | null): Promise<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, columnId);
|
||||
return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import { WebhooksManager } from './WebhooksManager';
|
||||
import {
|
||||
WebhookArkTypeCreate,
|
||||
WebhookArkTypeUpdate,
|
||||
WebhookArkTypeDelete,
|
||||
WebhookArkTypeFetch,
|
||||
WebhookArkTypeById,
|
||||
WebhookArkTypeFetchDeliveries,
|
||||
} from './types';
|
||||
|
||||
export class WebhooksController {
|
||||
private readonly manager = new WebhooksManager();
|
||||
|
||||
create = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeCreate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.create(data);
|
||||
if (!result) return res.status(500).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
update = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeUpdate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.update(data);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeDelete(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.delete(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeFetch(req.query);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.fetchByGoalId(data.goalId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
rotateSecret = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.rotateSecret(data.id);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
testDelivery = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.testDelivery(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchDeliveries = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeFetchDeliveries({ ...req.params, ...req.query });
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.fetchDeliveries(data.id, {
|
||||
cursor: data.cursor,
|
||||
status: data.status,
|
||||
});
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
retryDelivery = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.retryDelivery(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { TasksSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { Database } from '../../modules/db';
|
||||
import { WebhooksRepository } from './WebhooksRepository';
|
||||
import { WebhooksManager } from './WebhooksManager';
|
||||
import type { WebhookDeliverJobData } from './types';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
|
||||
const WEBHOOK_DELIVER_JOB = 'webhook-deliver';
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
export class WebhooksDispatcher implements Dispatcher {
|
||||
private readonly repository = new WebhooksRepository();
|
||||
private readonly manager = new WebhooksManager();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.dispatch('task.created', data.task.goalId, data));
|
||||
eventBus.on('task.updated', (data) => this.dispatch('task.updated', data.task.goalId, data));
|
||||
eventBus.on('task.deleted', (data) => this.dispatch('task.deleted', data.goalId, data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.dispatchAssigneesChanged(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(WEBHOOK_DELIVER_JOB);
|
||||
await boss.work<WebhookDeliverJobData>(WEBHOOK_DELIVER_JOB, async ([job]) => {
|
||||
await this.deliverJob(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatch(event: string, goalId: number, payload: object): Promise<void> {
|
||||
const webhooks = await this.repository.fetchActiveByGoalIdAndEvent(goalId, event);
|
||||
for (const webhook of webhooks) {
|
||||
await this.enqueueDelivery(webhook.id, webhook.url, webhook.secretEncrypted, event, {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
const db = Database.getInstance();
|
||||
const task = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, data.taskId)).limit(1);
|
||||
if (!task[0]) return;
|
||||
await this.dispatch('task.assigneesChanged', task[0].goalId, data);
|
||||
}
|
||||
|
||||
private async enqueueDelivery(webhookId: number, url: string, secretEncrypted: string, event: string, payload: object): Promise<void> {
|
||||
const delivery = await this.repository.createDelivery({ webhookId, event, payload });
|
||||
if (!delivery) return;
|
||||
|
||||
const boss = getJobQueue();
|
||||
await boss.send(WEBHOOK_DELIVER_JOB, {
|
||||
deliveryId: delivery.id,
|
||||
webhookId,
|
||||
url,
|
||||
secretEncrypted,
|
||||
payload,
|
||||
attempt: 1,
|
||||
} satisfies WebhookDeliverJobData);
|
||||
}
|
||||
|
||||
private async deliverJob(data: WebhookDeliverJobData): Promise<void> {
|
||||
const secret = decrypt(data.secretEncrypted);
|
||||
const result = await this.manager.deliver(data.url, secret, data.payload);
|
||||
|
||||
await this.repository.updateDelivery(data.deliveryId, {
|
||||
status: result.success ? 'success' : (data.attempt >= MAX_ATTEMPTS ? 'failed' : 'pending'),
|
||||
responseCode: result.responseCode,
|
||||
attempts: data.attempt,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.repository.resetConsecutiveFailures(data.webhookId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.attempt < MAX_ATTEMPTS) {
|
||||
const boss = getJobQueue();
|
||||
const delay = Math.pow(2, data.attempt) * 5;
|
||||
await boss.send(WEBHOOK_DELIVER_JOB, {
|
||||
...data,
|
||||
attempt: data.attempt + 1,
|
||||
}, { startAfter: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
const failures = await this.repository.incrementConsecutiveFailures(data.webhookId);
|
||||
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
await this.repository.deactivate(data.webhookId);
|
||||
$logger.warn(`[Webhooks] Deactivated webhook=${data.webhookId} after ${failures} consecutive failures`);
|
||||
} else {
|
||||
$logger.warn(`[Webhooks] Delivery failed for webhook=${data.webhookId}, consecutive failures: ${failures}/${MAX_CONSECUTIVE_FAILURES}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { randomBytes, createHmac } from 'crypto';
|
||||
import { encrypt, decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { WebhooksRepository } from './WebhooksRepository';
|
||||
import type { WebhookArgCreate, WebhookArgUpdate } from './types';
|
||||
import type { WebhooksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type WebhookForClient = Omit<WebhooksSchemaTypeForSelect, 'secretEncrypted'>;
|
||||
|
||||
export class WebhooksManager {
|
||||
public readonly repository: WebhooksRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new WebhooksRepository();
|
||||
}
|
||||
|
||||
async create(data: WebhookArgCreate): Promise<{ webhook: WebhookForClient; secret: string } | null> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const secretEncrypted = encrypt(secret);
|
||||
|
||||
const webhook = await this.repository.create({
|
||||
goalId: data.goalId,
|
||||
url: data.url,
|
||||
secretEncrypted,
|
||||
events: data.events,
|
||||
});
|
||||
|
||||
if (!webhook) return null;
|
||||
|
||||
return { webhook: this.toClient(webhook), secret };
|
||||
}
|
||||
|
||||
async update(data: WebhookArgUpdate): Promise<WebhookForClient | null> {
|
||||
const updateData: Partial<{ url: string; events: string[]; isActive: boolean }> = {};
|
||||
if (data.url !== undefined) updateData.url = data.url;
|
||||
if (data.events !== undefined) updateData.events = data.events;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
const webhook = await this.repository.update(data.id, updateData);
|
||||
if (!webhook) return null;
|
||||
return this.toClient(webhook);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhookForClient[]> {
|
||||
const webhooks = await this.repository.fetchByGoalId(goalId);
|
||||
return webhooks.map(w => this.toClient(w));
|
||||
}
|
||||
|
||||
async rotateSecret(id: number): Promise<{ secret: string } | null> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const secretEncrypted = encrypt(secret);
|
||||
const success = await this.repository.updateSecret(id, secretEncrypted);
|
||||
if (!success) return null;
|
||||
return { secret };
|
||||
}
|
||||
|
||||
async testDelivery(id: number): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const webhook = await this.repository.fetchById(id);
|
||||
if (!webhook) return { success: false };
|
||||
|
||||
const secret = decrypt(webhook.secretEncrypted);
|
||||
const payload = {
|
||||
event: 'webhook.test',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { message: 'This is a test webhook delivery' },
|
||||
};
|
||||
|
||||
return this.deliver(webhook.url, secret, payload);
|
||||
}
|
||||
|
||||
async deliver(url: string, secret: string, payload: object): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', secret).update(body).digest('hex');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
return { success: response.ok, responseCode: response.status };
|
||||
} catch (err) {
|
||||
$logger.error(err, `[Webhooks] Delivery failed to ${url}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async retryDelivery(deliveryId: number): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const deliveries = await this.repository.fetchDeliveryById(deliveryId);
|
||||
if (!deliveries) return { success: false };
|
||||
|
||||
const webhook = await this.repository.fetchById(deliveries.webhookId);
|
||||
if (!webhook) return { success: false };
|
||||
|
||||
const secret = decrypt(webhook.secretEncrypted);
|
||||
const result = await this.deliver(webhook.url, secret, deliveries.payload as object);
|
||||
|
||||
await this.repository.updateDelivery(deliveryId, {
|
||||
status: result.success ? 'success' : 'failed',
|
||||
responseCode: result.responseCode,
|
||||
attempts: deliveries.attempts + 1,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string }) {
|
||||
return this.repository.fetchDeliveries(webhookId, options);
|
||||
}
|
||||
|
||||
private toClient(webhook: WebhooksSchemaTypeForSelect): WebhookForClient {
|
||||
const { secretEncrypted, ...rest } = webhook;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { and, desc, eq, lt, sql } from 'drizzle-orm';
|
||||
import { WebhooksSchema, WebhookDeliveriesSchema, type WebhooksSchemaTypeForSelect, type WebhookDeliveriesSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
|
||||
export class WebhooksRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { goalId: number; url: string; secretEncrypted: string; events: string[] }): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(WebhooksSchema).values(data).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<{ url: string; events: string[]; isActive: boolean }>): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, id))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateSecret(id: number, secretEncrypted: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ secretEncrypted, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, id))
|
||||
.returning()
|
||||
);
|
||||
return (result?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.goalId, goalId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchActiveByGoalIdAndEvent(goalId: number, event: string): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(
|
||||
and(
|
||||
eq(WebhooksSchema.goalId, goalId),
|
||||
eq(WebhooksSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return (result ?? []).filter(w => w.events.includes(event));
|
||||
}
|
||||
|
||||
async createDelivery(data: { webhookId: number; event: string; payload: unknown }): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(WebhookDeliveriesSchema).values({
|
||||
webhookId: data.webhookId,
|
||||
event: data.event,
|
||||
payload: data.payload,
|
||||
}).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateDelivery(id: number, data: { status: string; responseCode?: number; attempts: number }): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhookDeliveriesSchema)
|
||||
.set({ ...data, lastAttemptAt: new Date() })
|
||||
.where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDeliveryById(id: number): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhookDeliveriesSchema).where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string; limit?: number }): Promise<WebhookDeliveriesSchemaTypeForSelect[]> {
|
||||
const limit = options?.limit ?? 20;
|
||||
const conditions = [eq(WebhookDeliveriesSchema.webhookId, webhookId)];
|
||||
|
||||
if (options?.cursor) {
|
||||
conditions.push(lt(WebhookDeliveriesSchema.id, options.cursor));
|
||||
}
|
||||
if (options?.status) {
|
||||
conditions.push(eq(WebhookDeliveriesSchema.status, options.status));
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhookDeliveriesSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(WebhookDeliveriesSchema.id))
|
||||
.limit(limit)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async resetConsecutiveFailures(webhookId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: 0 })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
|
||||
async incrementConsecutiveFailures(webhookId: number): Promise<number> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: sql`${WebhooksSchema.consecutiveFailures} + 1` })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
.returning({ consecutiveFailures: WebhooksSchema.consecutiveFailures })
|
||||
);
|
||||
return result?.[0]?.consecutiveFailures ?? 0;
|
||||
}
|
||||
|
||||
async deactivate(webhookId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { WebhooksController } from './WebhooksController';
|
||||
|
||||
export default class WebhooksRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: WebhooksController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new WebhooksController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.post('', [IsLoggedIn], this.controller.create);
|
||||
this.router.patch('', [IsLoggedIn], this.controller.update);
|
||||
this.router.delete('', [IsLoggedIn], this.controller.delete);
|
||||
this.router.post('/rotate-secret', [IsLoggedIn], this.controller.rotateSecret);
|
||||
this.router.post('/test', [IsLoggedIn], this.controller.testDelivery);
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn], this.controller.fetchDeliveries);
|
||||
this.router.post('/retry', [IsLoggedIn], this.controller.retryDelivery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
const NumberFromString = type('string|number').pipe((v) => Number(v));
|
||||
|
||||
export const WebhookArkTypeCreate = type({
|
||||
goalId: 'number',
|
||||
url: 'string',
|
||||
events: 'string[]',
|
||||
});
|
||||
|
||||
export type WebhookArgCreate = typeof WebhookArkTypeCreate.infer;
|
||||
|
||||
export const WebhookArkTypeUpdate = type({
|
||||
id: 'number',
|
||||
'url?': 'string',
|
||||
'events?': 'string[]',
|
||||
'isActive?': 'boolean',
|
||||
});
|
||||
|
||||
export type WebhookArgUpdate = typeof WebhookArkTypeUpdate.infer;
|
||||
|
||||
export const WebhookArkTypeDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
|
||||
export type WebhookArgDelete = typeof WebhookArkTypeDelete.infer;
|
||||
|
||||
export const WebhookArkTypeFetch = type({
|
||||
goalId: NumberFromString,
|
||||
});
|
||||
|
||||
export type WebhookArgFetch = typeof WebhookArkTypeFetch.infer;
|
||||
|
||||
export const WebhookArkTypeById = type({
|
||||
id: NumberFromString,
|
||||
});
|
||||
|
||||
export type WebhookArgById = typeof WebhookArkTypeById.infer;
|
||||
|
||||
const OptionalNumberFromString = type('string|number|undefined').pipe((v) => v === undefined ? undefined : Number(v));
|
||||
|
||||
export const WebhookArkTypeFetchDeliveries = type({
|
||||
id: NumberFromString,
|
||||
'cursor?': OptionalNumberFromString,
|
||||
'status?': 'string',
|
||||
});
|
||||
|
||||
export type WebhookArgFetchDeliveries = typeof WebhookArkTypeFetchDeliveries.infer;
|
||||
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'task.created',
|
||||
'task.updated',
|
||||
'task.deleted',
|
||||
'task.assigneesChanged',
|
||||
] as const;
|
||||
|
||||
export type WebhookEvent = typeof WEBHOOK_EVENTS[number];
|
||||
|
||||
export interface WebhookDeliverJobData {
|
||||
deliveryId: number;
|
||||
webhookId: number;
|
||||
url: string;
|
||||
secretEncrypted: string;
|
||||
payload: object;
|
||||
attempt: number;
|
||||
}
|
||||
@@ -131,6 +131,9 @@ export const GoalPermissions = {
|
||||
TASKS_CAN_RECOVERY_HISTORY: 'task_can_recovery_history',
|
||||
TASKS_CAN_ASSIGN_USERS: 'task_can_assign_users',
|
||||
TASKS_CAN_WATCH_ASSIGNED_USERS: 'task_can_watch_assigned_users',
|
||||
|
||||
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
|
||||
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
|
||||
} as const;
|
||||
|
||||
export type PermissionsEntityType =
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY;
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
||||
}
|
||||
return Buffer.from(hex, 'hex');
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string): string {
|
||||
const key = getKey();
|
||||
const [ivHex, authTagHex, dataHex] = encrypted.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const data = Buffer.from(dataHex, 'hex');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: TaskView Documentation
|
||||
description: Official documentation for TaskView - a source-available, self-hosted project and task management platform. Installation guides, feature docs, configuration reference, and more.
|
||||
navigation: false
|
||||
---
|
||||
|
||||
Welcome to the TaskView documentation. TaskView is a self-hosted task management platform for teams and individuals who want full control over their data and workflows.
|
||||
|
||||
## Getting started
|
||||
|
||||
::card-group
|
||||
::card{title="What is TaskView" icon="i-lucide-info" to="/docs/getting-started"}
|
||||
Learn what TaskView is, who it's for, and what features it offers.
|
||||
::
|
||||
::card{title="Installation" icon="i-lucide-download" to="/docs/getting-started/installation"}
|
||||
Deploy TaskView with Docker Compose in 5 minutes.
|
||||
::
|
||||
::card{title="Quick Start" icon="i-lucide-rocket" to="/docs/getting-started/usage"}
|
||||
Create your first project, add lists, and start managing tasks.
|
||||
::
|
||||
::
|
||||
|
||||
## Explore
|
||||
|
||||
::card-group
|
||||
::card{title="Features" icon="i-lucide-layout-grid" to="/docs/features/projects-and-lists"}
|
||||
Projects, tasks, Kanban boards, dependency graphs, and dashboard.
|
||||
::
|
||||
::card{title="Integrations" icon="i-lucide-git-pull-request" to="/docs/integrations/setup"}
|
||||
Connect GitHub and GitLab repositories to sync issues as tasks.
|
||||
::
|
||||
::card{title="Configuration" icon="i-lucide-settings" to="/docs/configuration/environment-variables"}
|
||||
Environment variables, authentication, and server setup.
|
||||
::
|
||||
::card{title="Collaboration" icon="i-lucide-users" to="/docs/collaboration/members"}
|
||||
Team members, roles, and 28 granular permissions.
|
||||
::
|
||||
::card{title="FAQ" icon="i-lucide-circle-help" to="/docs/faq"}
|
||||
Common questions about installation, features, and security.
|
||||
::
|
||||
::card{title="Guides" icon="i-lucide-book-open" to="/docs/guides/deploy-vps-nginx"}
|
||||
Step-by-step guides for production deployment and use cases.
|
||||
::
|
||||
::
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Getting Started
|
||||
icon: false
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: What is TaskView
|
||||
description: TaskView is an open-source, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
|
||||
navigation:
|
||||
icon: i-lucide-house
|
||||
---
|
||||
|
||||
TaskView is a self-hosted task management platform for teams and individuals who want full control over their data and workflows.
|
||||
|
||||
You deploy it on your own server (or run it locally), and everything - tasks, projects, files, user data - stays on your infrastructure. There are no third-party clouds involved, no subscriptions, and no vendor lock-in.
|
||||
|
||||
## Who is it for
|
||||
|
||||
- **Teams with security requirements** - companies that can't send project data to external SaaS platforms
|
||||
- **Self-hosters** - people who prefer running their own tools, like Gitea instead of GitHub or Mattermost instead of Slack
|
||||
- **Small teams and startups** - anyone who wants a capable project manager without paying per seat
|
||||
|
||||
## What you get
|
||||
|
||||
- **Projects and lists** - organize work into projects, each with its own lists, tags, statuses, and team members
|
||||
- **Tasks and subtasks** - create tasks with priorities, deadlines, notes
|
||||
- **Kanban boards** - drag-and-drop tasks with custom statuses per project
|
||||
- **Dependency graphs** - link tasks and visualize dependencies on an interactive graph
|
||||
- **Team collaboration** - invite members, assign roles with granular permissions, control who sees what
|
||||
- **GitHub and GitLab sync** - connect repositories and import issues as tasks, kept in sync via webhooks
|
||||
- **Financial tracking** - attach income and expense amounts to tasks for basic budget tracking
|
||||
- **Task history** - full audit trail with the ability to restore deleted or changed tasks (only props in tasks, not other entities)
|
||||
- **Mobile apps** - Android and iOS apps that sync with your server
|
||||
- **Dashboard** - widgets for today's tasks, upcoming deadlines, recent activity, and completed work
|
||||
|
||||
## Tech stack
|
||||
|
||||
TaskView is a monorepo with three main parts:
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| API server | Node.js, Express, Drizzle ORM, SQL, TypeScript |
|
||||
| Web app | Vue 3, Nuxt UI, TailwindCSS, Pinia, TypeScript |
|
||||
| Database | PostgreSQL 17 |
|
||||
| Mobile | Capacitor 8 (iOS & Android) |
|
||||
|
||||
Everything runs in Docker containers, so deployment is straightforward regardless of your server setup.
|
||||
|
||||
## What's next
|
||||
|
||||
Head to the [Installation](/docs/getting-started/installation) page to get TaskView running on your machine in a few minutes.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Install and deploy TaskView using Docker Compose. Step-by-step setup guide for a self-hosted task management server with PostgreSQL, Node.js API, and Vue web app. Deploy on any server in 5 minutes.
|
||||
navigation:
|
||||
icon: i-lucide-download
|
||||
---
|
||||
|
||||
TaskView runs as a set of Docker containers - a database, an API server, a web app, and a one-time migration runner. The whole setup takes about 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A server or local machine with [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed
|
||||
- Ports `8888` (web) and `1725` (API) available - you can change these in the compose file
|
||||
|
||||
## Step 1: Create a project directory
|
||||
|
||||
```bash
|
||||
mkdir taskview && cd taskview
|
||||
```
|
||||
|
||||
## Step 2: Create environment files
|
||||
|
||||
You need two env files - one for PostgreSQL, one for the TaskView API.
|
||||
|
||||
**`.env.postgresql`** - database credentials:
|
||||
|
||||
```env
|
||||
POSTGRES_USER=taskview_db_user
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=taskviewdb
|
||||
```
|
||||
|
||||
**`.env.taskview`** - application config (**example, do not forget add your data**):
|
||||
|
||||
```env
|
||||
DB_HOST="db"
|
||||
DB_USER="taskview_db_user"
|
||||
DB_PASSWORD="your_secure_password"
|
||||
DB_NAME="taskviewdb"
|
||||
DB_PORT=5432
|
||||
APP_PORT=1401
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="secret"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
REFRESH_LIFE_TIME="9d"
|
||||
|
||||
SMTP_HOST=smtp
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_ENCRYPTION=tls
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
# Your domain
|
||||
APP_URL="https://app.taskview.tech"
|
||||
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
#You domain
|
||||
GOOGLE_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/google/callback"
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
GITHUB_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/github/callback"
|
||||
APPLE_CLIENT_ID=""
|
||||
APPLE_TEAM_ID=""
|
||||
APPLE_KEY_ID=""
|
||||
APPLE_KEY_LOCATION="/usr/src/app/AuthKey.p8"
|
||||
# Your domain
|
||||
APPLE_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/apple/callback"
|
||||
|
||||
#integrations
|
||||
GITHUB_INTEGRATION_CLIENT_ID=
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback
|
||||
|
||||
GITLAB_INTEGRATION_CLIENT_ID=
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback
|
||||
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
#!!! ADD YOUR DOMAIN SEPARATED BY ","
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://localhost:8888,http://127.0.0.1:3000,http://127.0.0.1:8888"
|
||||
```
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
Replace `your_secure_password` and `JWT_SIGN` with real secrets. Never use the example values in production.
|
||||
::
|
||||
|
||||
## Step 3: Create docker-compose.yml
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
backend:
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env.postgresql
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U taskview_db_user -d taskviewdb"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks: [backend]
|
||||
migration:
|
||||
image: gimanhead/taskview-ce-db-migration:latest
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./.env.taskview
|
||||
networks: [backend]
|
||||
|
||||
taskview-api-server:
|
||||
image: gimanhead/taskview-ce-api-server:latest
|
||||
restart: "unless-stopped"
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=1
|
||||
- net.ipv6.conf.default.disable_ipv6=1
|
||||
ports:
|
||||
- "1725:1401"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- ./.env.taskview
|
||||
volumes:
|
||||
- ./logs:/usr/src/app/logs
|
||||
#- /local/AuthKey.p8:/usr/src/app/AuthKey.p8
|
||||
networks: [backend]
|
||||
|
||||
taskview-webapp:
|
||||
image: gimanhead/taskview-ce-webapp:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:80"
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
```
|
||||
|
||||
## Step 4: Start everything
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Docker will pull the images, start the database, run migrations, and launch the API and web app.
|
||||
|
||||
## Step 5: Open TaskView
|
||||
|
||||
Go to [http://localhost:8888](http://localhost:8888) in your browser. You'll see the login screen.
|
||||
|
||||
The database migration creates a default user so you can log in right away:
|
||||
|
||||
- **Login:** `user`
|
||||
- **Password:** `user1!#Q`
|
||||
|
||||
Use these credentials to verify that everything is working - check that the UI loads, you can create a project, add tasks, etc.
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="error"}
|
||||
**Important:** The default user is for initial setup only. Once you've confirmed the system works, delete the default user and create your own account with a secure password.
|
||||
::
|
||||
|
||||
### Replacing the default user
|
||||
|
||||
1. Log in with the default credentials
|
||||
2. Register a new account with your real email and a strong password
|
||||
3. Delete the default `admin` account
|
||||
|
||||
If you prefer to create the first user directly in the database, generate a password hash:
|
||||
|
||||
```ts
|
||||
import { hashSync } from 'bcryptjs'
|
||||
|
||||
const passwordHash = hashSync('your-secure-password', 12)
|
||||
console.log(passwordHash)
|
||||
```
|
||||
|
||||
Or as a one-liner:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('bcryptjs').hashSync('your-secure-password', 12))"
|
||||
```
|
||||
|
||||
Then insert the user into the database with the generated hash.
|
||||
|
||||
## Updating
|
||||
|
||||
To update TaskView to a new version:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The migration container will automatically apply any new database changes on startup.
|
||||
|
||||
## Production tips
|
||||
|
||||
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
|
||||
- **Update `APP_URL` and `API_URL`** in `.env.taskview` to match your production domain
|
||||
- **Back up the database** - the `pgdata` volume contains all your data
|
||||
- **Set `restart: unless-stopped`** on all services so they survive server reboots
|
||||
- **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details.
|
||||
|
||||
## What's next
|
||||
|
||||
- [Create your first project](/docs/features/projects-and-lists) - set up a project with lists and tasks
|
||||
- [Invite your team](/docs/collaboration/members) - add members and assign roles
|
||||
- [Connect GitHub or GitLab](/docs/integrations/setup) - sync issues as tasks
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Get started with TaskView in 5 minutes - create projects, add task lists, organize work with Kanban boards and dependency graphs. Quick start guide for self-hosted project and task management.
|
||||
navigation:
|
||||
icon: i-lucide-rocket
|
||||
---
|
||||
|
||||
You've installed TaskView and created an account. Here's how to get productive in 5 minutes.
|
||||
|
||||
## Create a project
|
||||
|
||||

|
||||
|
||||
Click the **+** button in the sidebar to create your first project. Give it a name, and you're done.
|
||||
|
||||
A project is a top-level container for all your work - it has its own lists, tags, statuses, team members, and permissions.
|
||||
|
||||
## Add lists
|
||||
|
||||
Inside a project, create lists to organize tasks into groups. Think of lists as folders - "Backend", "Frontend", "Design", "Bugs", whatever makes sense for your workflow.
|
||||
|
||||
Enter list name in the header and press enter. You can ignore lists creation and create tasks in the created project directly.
|
||||
|
||||
## Create tasks
|
||||
|
||||

|
||||
|
||||
Click inside a list and start adding tasks. Each task can have:
|
||||
|
||||
- **Priority** - how urgent it is
|
||||
- **Deadline** - when it's due (with optional time)
|
||||
- **Notes** - rich text description with formatting
|
||||
- **Tags** - color-coded labels for categorization
|
||||
- **Subtasks** - break work into smaller pieces, as deep as you need
|
||||
- **Assignees** - who's responsible (multiple people allowed)
|
||||
- **Financial amount** - attach an income or expense for budget tracking
|
||||
- **Task history** - every change to a task's own properties (title, description, priority, deadline, status, etc.) is tracked and can be restored. Note: changes to tags, assignees, and other related entities are not part of the history - only fields stored directly in the task record.
|
||||
|
||||
## Switch views
|
||||
|
||||
TaskView gives you three ways to look at your work:
|
||||
|
||||
### List view
|
||||
|
||||

|
||||
The default view. Tasks grouped by list, with all details visible. Best for day-to-day task management.
|
||||
|
||||
### Kanban board
|
||||
|
||||

|
||||
Visual columns representing statuses. Drag tasks between columns to update their status. Great for tracking workflow stages like "Backlog → To Do → In Progress → Done".
|
||||
|
||||
You can customize the columns - each project has its own set of statuses.
|
||||
|
||||
### Dependency graph
|
||||
|
||||

|
||||
|
||||
An interactive network graph showing how tasks connect to each other. Link tasks to define dependencies, then zoom out to see the big picture. Useful for planning complex work where order matters.
|
||||
|
||||
## Use the dashboard
|
||||
|
||||

|
||||
The main screen (home page) shows a dashboard with widgets:
|
||||
|
||||
- **Today's tasks** - what's due today
|
||||
- **Upcoming deadlines** - what's coming soon
|
||||
- **Recent activity** - latest changes across all projects
|
||||
- **Completed tasks** - what's been done
|
||||
|
||||
This gives you a quick overview without opening any specific project.
|
||||
|
||||
## Search
|
||||
|
||||
Use the global search (click the search icon or press `Ctrl+K` / `Cmd+K`) to find any task across all your projects. Filter by tags, priorities, statuses, or assignees (available only in the selected project).
|
||||
|
||||
## What's next
|
||||
|
||||
- [Learn about Kanban boards](/docs/features/kanban) - customize columns and workflow
|
||||
- [Set up task dependencies](/docs/features/graph) - link related tasks on the graph
|
||||
- [Invite your team](/docs/collaboration/members) - collaborate with others
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
@@ -0,0 +1,2 @@
|
||||
title: Features
|
||||
icon: false
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Projects and Lists
|
||||
description: Organize work with projects, task lists, and color-coded tags in TaskView. Each project has independent members, roles, statuses, permissions, and archiving. Flexible workspace management for teams.
|
||||
navigation:
|
||||
icon: i-lucide-folder
|
||||
---
|
||||
|
||||
Everything in TaskView starts with a project. A project is a workspace that contains lists, tasks, team members, tags, statuses, and permissions - all scoped to that project.
|
||||
|
||||
## Projects
|
||||
|
||||
### Creating a project
|
||||
|
||||
Click the **Enter project name** input in the sidebar. Enter a name, and hit save. That's it - you can start adding lists and tasks right away.
|
||||
|
||||

|
||||
|
||||
### Project settings
|
||||
|
||||

|
||||
Click the **more button** a project in the sidebar to access:
|
||||
|
||||
- **Rename** - change the project name or color
|
||||
- **Archive** - hide the project without deleting it (you can restore it later)
|
||||
- **Delete** - permanently remove the project and all its data
|
||||
- **Integrations** - connect GitHub or GitLab repositories
|
||||
- **Collaboration** - manage team members and permissions
|
||||
|
||||
### Archiving
|
||||
|
||||
If you're done with a project but want to keep the data around, archive it instead of deleting it. Archived projects disappear from the sidebar but can be restored at any time.
|
||||
|
||||
## Lists
|
||||
|
||||
Lists live inside projects. They're a way to group related tasks - by feature, by team, by phase, or however you prefer.
|
||||
|
||||
### Creating a list
|
||||
|
||||
Click the **Enter list name** input in the header. Give the list a name and it appears as a section within the project.
|
||||
|
||||
### Deleting a list
|
||||
|
||||
Click a list **More button** and choose **Delete**. This removes the list and all tasks inside it. There's no undo for this, so make sure you really want to do it.
|
||||
|
||||
## Tags
|
||||
|
||||
Each project has its own set of tags. Tags are color-coded labels you attach to tasks for quick visual identification.
|
||||
|
||||
### Managing tags
|
||||
|
||||
Go to a project and open the task **Detailed form** by clicking to the task and scroll to the tag management panel. You can:
|
||||
|
||||
- Create tags with a name and color
|
||||
- Edit existing tags
|
||||
- Delete tags (they'll be removed from all tasks that use them)
|
||||
|
||||
### Tagging tasks
|
||||
|
||||
Open a task and click the tags area. Select one or more tags from the list. You can filter tasks by tag in the list view.
|
||||
|
||||
## Best practices
|
||||
|
||||
- **One project per real-world project** - don't try to fit everything into a single project. Each project gets its own permissions, tags, and statuses.
|
||||
- **Keep list names short** - "Backend", "Bugs", "Sprint 14" work better than long descriptions.
|
||||
- **Use tags for cross-cutting concerns** - things like "urgent", "blocked", "needs-review" that apply across multiple lists.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Tasks
|
||||
description: Create and manage tasks in TaskView - subtasks, deadlines, priorities, assignees, tags, rich-text notes, financial tracking, and full change history with restore. Self-hosted task tracking with no limits.
|
||||
navigation:
|
||||
icon: i-lucide-check-square
|
||||
---
|
||||
|
||||
Tasks are the core of TaskView. Every piece of work - a bug to fix, a feature to build, a meeting to prepare - is a task.
|
||||
|
||||
## Creating tasks
|
||||
|
||||
Click inside any list to add a task. Type a title and press Enter. The task is created immediately - you can add details later.
|
||||
|
||||
## Task details
|
||||
|
||||

|
||||
Click on a task to open the detail panel. Here you can set:
|
||||
|
||||
### Priority
|
||||
How urgent this task is. Priorities help you and your team focus on what matters most. Tasks can be sorted by priority in the list view.
|
||||
|
||||
### Deadline
|
||||
When the task is due. You can set just a date, or a date with a specific time. Quick shortcuts are available for common choices like "Today", "This week", and "This month".
|
||||
|
||||
The dashboard will show upcoming deadlines so nothing slips through.
|
||||
|
||||
### Notes
|
||||
A rich text editor for longer descriptions, steps, links, or anything else. Supports formatting, headings, lists, and code blocks.
|
||||
|
||||
### Assignees
|
||||
Who's working on this. You can assign multiple people to a single task. Each assignee can have a different role (responsible, participant) depending on your project setup.
|
||||
|
||||
### Tags
|
||||
Color-coded labels for categorization. A task can have multiple tags. Tags are defined per project.
|
||||
|
||||
### Status
|
||||
The workflow state of the task - tied to your Kanban columns. Status can only be changed from the Kanban board by dragging the task between columns. There is readonly status selector in the task detail panel.
|
||||
|
||||
### Financial amount
|
||||
Attach a monetary amount to a task and mark it as income or expense. Useful for freelancers or teams that need basic budget tracking alongside task management.
|
||||
|
||||
## Subtasks
|
||||
|
||||
Any task can have subtasks.
|
||||
|
||||
To create a subtask, open a task and click the **Add subtasks** in the subtasks section. Subtasks don't have priorities or other detailed properties - they're meant to break a task into small, easy-to-complete steps.
|
||||
|
||||
Completing a parent task doesn't automatically complete its subtasks. You can use this to track whether all the pieces of a larger task are actually done.
|
||||
|
||||
## Task completion
|
||||
|
||||
Click the checkbox next to a task to mark it complete. Completed tasks are hidden from the list by default. To see them, click the **eye** button in the toolbar - completed tasks will appear dimmed alongside active ones. They also show up in the dashboard's "Completed" widget.
|
||||
|
||||
To reopen a completed task, just click the checkbox again.
|
||||
|
||||
## Task history
|
||||
|
||||
TaskView keeps a history of changes for every task. If something was accidentally changed you can restore it.
|
||||
|
||||
Open a task, go to the history section, and you'll see a log of what changed and when. Click **Restore** on any previous version to bring it back.
|
||||
|
||||
## Deleting tasks
|
||||
|
||||
Delete a task from the context menu or the detail panel. Deleted tasks go through the history system, so you **can not** recover them.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Kanban Board
|
||||
description: Kanban board in TaskView - drag-and-drop task cards, customizable status columns per project, and visual workflow management. Self-hosted alternative to Trello with full data control.
|
||||
navigation:
|
||||
icon: i-lucide-columns-3
|
||||
---
|
||||
|
||||
The Kanban board gives you a visual overview of your project's workflow. Tasks are displayed as cards in columns, where each column represents a status (like "To Do", "In Progress", "Done").
|
||||
|
||||
## Opening the Kanban view
|
||||
|
||||

|
||||
|
||||
Select a project in the sidebar, then click the **Kanban** button in the context menu. You'll see all your tasks arranged in columns.
|
||||
|
||||
## Columns are statuses
|
||||
|
||||
Each column on the Kanban board is a **status**. Every project has its own set of statuses, so you can customize the workflow for each project independently.
|
||||
|
||||
By default, new projects have Backlog, TODO, In Progress, Done.
|
||||
|
||||
### Adding a column
|
||||
|
||||
Click the **Add column** button on the board to add a new status column. Give it a name - "Backlog", "In Progress", "Review", "Done", or whatever fits your workflow.
|
||||
|
||||
### Editing a column
|
||||
|
||||
Click the column header to rename it or change its properties.
|
||||
|
||||
### Deleting a column
|
||||
|
||||
Remove a column from the column settings. Tasks in that column will need to be moved to another status first.
|
||||
|
||||
### Reordering columns
|
||||
|
||||
**Column reordering is not supported yet** - columns are displayed in the order they were created. Keep this in mind when adding new columns and create them in the order you want. This will be fixed in a future version.
|
||||
|
||||
## Moving tasks
|
||||
|
||||
Drag a task card from one column to another to change its status. The task's position within the column is also saved, so you can prioritize by dragging tasks up and down within the same column.
|
||||
|
||||
When you move a task on the Kanban board, the status change is reflected everywhere - in the list view, in the task detail panel, and in any filters.
|
||||
|
||||
## What you see on a card
|
||||
|
||||
Each Kanban card shows:
|
||||
|
||||
- Task title
|
||||
- Priority indicator
|
||||
- Deadline (if set)
|
||||
- Assigned users (avatars)
|
||||
- Tags (color badges)
|
||||
|
||||
Click a card to open the full task detail panel, where you can edit everything.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Start simple** - three columns ("To Do", "In Progress", "Done") are enough for most projects. Add more columns only when you actually need them.
|
||||
- **Limit work in progress** - if "In Progress" has 20 cards, nothing is really in progress. Keep the number manageable.
|
||||
- **Use the list view for bulk edits** - Kanban is great for visual tracking, but the list view is faster when you need to update many tasks at once.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Dependency Graph
|
||||
description: Visualize task dependencies with an interactive network graph in TaskView. Connect tasks, identify blockers and bottlenecks, plan work order, and manage complex project workflows visually.
|
||||
navigation:
|
||||
icon: i-lucide-git-branch
|
||||
---
|
||||
|
||||
The dependency graph shows how tasks in a project relate to each other. If task B can't start until task A is done, you create a dependency - and the graph makes that relationship visible.
|
||||
|
||||
## Opening the graph
|
||||
|
||||

|
||||

|
||||
Select a project in the sidebar, then click the **Graph** button. You'll see all your tasks as standalone nodes. By default, tasks have no dependencies - you connect them yourself by dragging edges between nodes to build the sequence you need.
|
||||
|
||||
## Creating dependencies
|
||||
|
||||
To link two tasks:
|
||||
|
||||
1. Open the graph view
|
||||
2. Drag from one task node to another to create a connection
|
||||
3. The arrow indicates the direction - "this task depends on that task"
|
||||
|
||||
You can also create dependencies from the task detail panel by selecting related tasks.
|
||||
|
||||
## Reading the graph
|
||||
|
||||
- **Nodes** are tasks. Their appearance reflects the task's current state (complete, in progress, overdue).
|
||||
- **Edges** are dependencies. An arrow from task A to task B means "B depends on A" - A should be done before B starts.
|
||||
- **Clusters** of heavily connected tasks show you where the complex work is.
|
||||
- **Isolated nodes** are tasks with no dependencies - they can be done anytime.
|
||||
|
||||
## Navigating
|
||||
|
||||
- **Zoom** in and out with the scroll wheel or pinch gesture
|
||||
- **Pan** by dragging the background
|
||||
- **Click** a node to select it and see the task details
|
||||
- **Minimap** in the corner shows your position in the full graph
|
||||
|
||||
## Removing dependencies
|
||||
|
||||
Click on an edge (the line between two tasks) and delete it. This only removes the dependency relationship - it doesn't affect the tasks themselves.
|
||||
|
||||
## When to use the graph
|
||||
|
||||
The graph is most useful when:
|
||||
|
||||
- You're planning a complex feature with many interconnected tasks
|
||||
- You need to figure out what to work on first (follow the arrows upstream)
|
||||
- You want to spot bottleneck tasks that block many other tasks
|
||||
- You're onboarding someone and want to show them how the work fits together
|
||||
|
||||
For simple projects with independent tasks, the list or Kanban view is usually enough.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Dashboard
|
||||
description: TaskView dashboard - smart widgets for today's tasks, upcoming deadlines, recent activity, completed work, and daily planning overview across all your projects.
|
||||
navigation:
|
||||
icon: i-lucide-layout-dashboard
|
||||
---
|
||||
|
||||
The dashboard is the first thing you see when you open TaskView. It pulls together the most important information from all your projects into one screen.
|
||||
|
||||
## Widgets
|
||||
|
||||

|
||||
|
||||
### Today's tasks
|
||||
Tasks due today across all projects. This is your daily focus list - what needs attention right now.
|
||||
|
||||
### Upcoming deadlines
|
||||
Tasks due in the coming days. Helps you plan ahead and avoid last-minute surprises.
|
||||
|
||||
### Recent activity
|
||||
A feed of recent changes - new tasks, completed tasks, updates. Useful for staying in the loop on what your team is doing.
|
||||
|
||||
### Completed tasks
|
||||
What's been finished recently. A satisfying way to see progress and confirm that work is actually getting done.
|
||||
|
||||
## How it works
|
||||
|
||||
The dashboard aggregates data across all projects you have access to. If you're a member of five projects, you'll see tasks from all five.
|
||||
|
||||
Tasks appear on the dashboard based on their deadlines and activity timestamps. There's no separate configuration - the dashboard just reflects the state of your tasks.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Check the dashboard first thing** - it gives you a clear picture of what to focus on today
|
||||
- **Use deadlines consistently** - the dashboard is only as useful as the data behind it. If your tasks don't have deadlines, the "Today" and "Upcoming" widgets won't be helpful.
|
||||
- **Don't ignore overdue tasks** - if something is overdue, either do it, move the deadline, or remove it. A growing list of overdue items makes the dashboard noisy.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Notifications
|
||||
description: Real-time and push notifications in TaskView - deadline alerts, assignment notifications, per-user preferences, and multi-channel delivery via WebSocket and Firebase Cloud Messaging.
|
||||
navigation:
|
||||
icon: i-lucide-bell
|
||||
---
|
||||
|
||||
TaskView notifies you when things happen in your projects. Notifications are delivered through multiple channels and can be customized per user.
|
||||
|
||||
## Notification types
|
||||
|
||||
| Type | When it fires | Delivery | Status |
|
||||
|---|---|---|---|
|
||||
| **Deadline** | When a task deadline is reached | Scheduled via background job (pgboss). If the deadline is already past when set, fires immediately. | Available |
|
||||
| **Assignment** | When you are assigned to a task | Immediate | Available |
|
||||
| **Mention** | When someone mentions you | Immediate | Planned |
|
||||
| **Comment** | When someone comments on your task | Immediate | Planned |
|
||||
| **Status change** | When a task status changes | Immediate | Planned |
|
||||
|
||||
## Delivery channels
|
||||
|
||||
Notifications can be sent through two channels (with email planned for the future):
|
||||
|
||||
| Channel | Description | Required configuration |
|
||||
|---|---|---|
|
||||
| **Push** | Native push notifications on iOS/Android via Firebase Cloud Messaging | `FIREBASE_CREDENTIALS_PATH` |
|
||||
| **In-app (WebSocket)** | Real-time delivery to the browser via Centrifugo | `CENTRIFUGO_API_URL`, `CENTRIFUGO_API_KEY`, `CENTRIFUGO_TOKEN_SECRET`, `CENTRIFUGO_PUBLIC_URL` |
|
||||
|
||||
Both channels are optional. If Firebase is not configured, push notifications are silently skipped. If Centrifugo is not configured, in-app real-time delivery is skipped. Notifications are always saved to the database regardless of channel availability.
|
||||
|
||||
## User preferences
|
||||
|
||||
Each user can control which notifications they receive and through which channels. Settings are available in **Account Settings > Notification Settings**.
|
||||
|
||||
Preferences follow an **opt-out model**: everything is enabled by default. Users explicitly disable what they do not want.
|
||||
|
||||
### Global and per-project settings
|
||||
|
||||
Preferences support two levels:
|
||||
|
||||
- **Global** applies to all projects
|
||||
- **Project overrides** apply to a specific project and are merged on top of global settings
|
||||
|
||||
For example, a user can enable push for all deadline notifications globally, but disable push for deadlines in a specific project.
|
||||
|
||||
### Deadline intervals (planned)
|
||||
|
||||
::callout{icon="i-lucide-construction" color="warning"}
|
||||
Deadline intervals are defined in the preferences structure but not yet active. Currently, deadline notifications fire once at the moment of the deadline. Multiple interval support is planned for a future release.
|
||||
::
|
||||
|
||||
The preferences structure supports the following intervals (minutes before the deadline):
|
||||
|
||||
| Interval | Description |
|
||||
|---|---|
|
||||
| `0` | At the moment of the deadline |
|
||||
| `15` | 15 minutes before |
|
||||
| `30` | 30 minutes before |
|
||||
| `60` | 1 hour before |
|
||||
| `1440` | 1 day before |
|
||||
|
||||
Each interval can be independently enabled or disabled.
|
||||
|
||||
## How it works
|
||||
|
||||
1. An event occurs (task created, deadline changed, assignees changed, etc.)
|
||||
2. **NotificationDispatcher** listens to the event bus and determines the notification type, recipients, and whether to send immediately or schedule a background job
|
||||
3. For deadlines, **DeadlineScheduler** creates a pgboss job that fires at the right time
|
||||
4. When it is time to deliver, **NotificationService** checks the user's preferences, saves the notification to the database, and sends it through enabled channels only
|
||||
5. **Providers** (FCMProvider, CentrifugoProvider) handle the actual delivery
|
||||
|
||||
## Viewing notifications
|
||||
|
||||
Click the bell icon in the sidebar to open the notification panel. From there you can:
|
||||
|
||||
- See all your notifications with type icons and timestamps
|
||||
- Click a notification to navigate to the related task
|
||||
- Mark individual notifications as read
|
||||
- Mark all notifications as read
|
||||
- Load older notifications via pagination
|
||||
|
||||
Notifications older than 1 day are automatically cleaned up by a daily background job.
|
||||
|
||||
## Configuration
|
||||
|
||||
See [Environment Variables](/docs/configuration/environment-variables#notifications) for the full list of notification-related variables.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/module/notifications` | Fetch notifications (cursor pagination) |
|
||||
| `PATCH` | `/module/notifications/read` | Mark a notification as read |
|
||||
| `PATCH` | `/module/notifications/read-all` | Mark all notifications as read |
|
||||
| `GET` | `/module/notifications/preferences` | Get user notification preferences |
|
||||
| `PUT` | `/module/notifications/preferences` | Save user notification preferences |
|
||||
| `GET` | `/module/notifications/connection-token` | Get WebSocket connection token |
|
||||
| `POST` | `/module/notifications/device/register` | Register a device for push notifications |
|
||||
| `POST` | `/module/notifications/device/unregister` | Unregister a device |
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: Configure webhooks in TaskView to receive real-time HTTP notifications when tasks are created, updated, deleted, or reassigned. Includes HMAC-SHA256 signature verification, automatic retries, and delivery history.
|
||||
navigation:
|
||||
icon: i-lucide-webhook
|
||||
---
|
||||
|
||||
Webhooks let you receive HTTP POST requests when events happen in your projects. Use them to integrate TaskView with external systems - CI/CD pipelines, Slack bots, custom dashboards, or any service that can accept HTTP requests.
|
||||
|
||||
## Supported events
|
||||
|
||||
| Event | When it fires |
|
||||
|---|---|
|
||||
| `task.created` | A new task is created in the project |
|
||||
| `task.updated` | A task is updated (description, status, priority, deadline, etc.) |
|
||||
| `task.deleted` | A task is deleted |
|
||||
| `task.assigneesChanged` | Task assignees are added or removed |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open a project in TaskView
|
||||
2. Right-click the project in the sidebar → **"Webhooks"**
|
||||
3. Click **"Add Webhook"**
|
||||
4. Enter the URL where you want to receive events
|
||||
5. Select which events to subscribe to
|
||||
6. Click **"Add"**
|
||||
7. Copy the secret and store it securely - it will not be shown again
|
||||
|
||||
## Payload format
|
||||
|
||||
Every webhook delivery is an HTTP POST with `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task.updated",
|
||||
"timestamp": "2026-03-22T12:00:00.000Z",
|
||||
"task": {
|
||||
"id": 123,
|
||||
"goalId": 774,
|
||||
"description": "Fix login bug",
|
||||
"complete": false,
|
||||
"statusId": 5,
|
||||
"priorityId": 2,
|
||||
"tags": [1, 3],
|
||||
"assignedUsers": [10, 22],
|
||||
"subtasks": []
|
||||
},
|
||||
"changes": {
|
||||
"statusId": 5
|
||||
},
|
||||
"initiatorId": 1
|
||||
}
|
||||
```
|
||||
|
||||
The `changes` field is only present on `task.updated` events and contains only the fields that changed.
|
||||
|
||||
## Signature verification
|
||||
|
||||
Every request includes an `X-Webhook-Signature` header with an HMAC-SHA256 signature of the request body:
|
||||
|
||||
```
|
||||
X-Webhook-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
|
||||
```
|
||||
|
||||
Always verify the signature before processing the payload. Example in Node.js:
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto')
|
||||
|
||||
function verifySignature(body, signature, secret) {
|
||||
const expected = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
return signature === expected
|
||||
}
|
||||
|
||||
// In your HTTP handler:
|
||||
const body = req.body // raw string, not parsed JSON
|
||||
const signature = req.headers['x-webhook-signature']
|
||||
const isValid = verifySignature(body, signature, YOUR_SECRET)
|
||||
```
|
||||
|
||||
::callout{icon="i-lucide-shield-alert" color="warning"}
|
||||
Never process webhook payloads without verifying the signature. Without verification, anyone who knows your webhook URL can send fake events.
|
||||
::
|
||||
|
||||
## Retries
|
||||
|
||||
If your server responds with a non-2xx status code or doesn't respond within 10 seconds, TaskView retries the delivery:
|
||||
|
||||
| Attempt | Delay |
|
||||
|---|---|
|
||||
| 1st retry | ~10 seconds |
|
||||
| 2nd retry | ~20 seconds |
|
||||
|
||||
After 3 total attempts (1 original + 2 retries), the delivery is marked as **failed**.
|
||||
|
||||
## Auto-deactivation
|
||||
|
||||
If a webhook accumulates **10 consecutive failed deliveries** (after all retries are exhausted), it is automatically deactivated. A single successful delivery resets the failure counter.
|
||||
|
||||
To reactivate a webhook, toggle it back on from the webhooks page. The failure counter is not reset automatically - the next successful delivery will reset it.
|
||||
|
||||
## Delivery history
|
||||
|
||||
The webhooks page shows delivery history for each webhook:
|
||||
- **Event** - which event was delivered
|
||||
- **Status** - success, failed, or pending
|
||||
- **HTTP code** - response status code from your server
|
||||
- **Attempts** - how many attempts were made
|
||||
- **Payload** - click to view the full JSON payload
|
||||
|
||||
Failed deliveries can be retried manually from the delivery history.
|
||||
|
||||
## Managing webhooks
|
||||
|
||||
From the webhooks page you can:
|
||||
- **Toggle** webhooks on/off
|
||||
- **Edit** the URL and subscribed events
|
||||
- **Test** - sends a test payload to verify connectivity
|
||||
- **Rotate secret** - generates a new secret (the old one stops working immediately)
|
||||
- **Delete** - removes the webhook and all delivery history
|
||||
- **View deliveries** - see delivery history with status filter
|
||||
|
||||
## Secret rotation
|
||||
|
||||
If your secret is compromised, rotate it:
|
||||
|
||||
1. Click the key icon on the webhook
|
||||
2. Confirm that you want to rotate
|
||||
3. Copy the new secret
|
||||
4. Update the secret in your receiving application
|
||||
|
||||
The old secret stops working immediately. Any in-flight deliveries signed with the old secret will fail signature verification on your end.
|
||||
|
||||
## Testing locally
|
||||
|
||||
You can use a simple Node.js script to test webhook deliveries:
|
||||
|
||||
```javascript
|
||||
const http = require('http')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const PORT = 4545
|
||||
const SECRET = 'your-secret-here'
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = []
|
||||
req.on('data', (chunk) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString()
|
||||
const signature = req.headers['x-webhook-signature'] || ''
|
||||
const expected = 'sha256=' + crypto
|
||||
.createHmac('sha256', SECRET)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
|
||||
console.log(signature === expected ? 'Valid' : 'INVALID')
|
||||
console.log(JSON.stringify(JSON.parse(body), null, 2))
|
||||
|
||||
res.writeHead(200)
|
||||
res.end('OK')
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(PORT, () => console.log(`Listening on :${PORT}`))
|
||||
```
|
||||
|
||||
Run with `node webhook-receiver.js` and set the webhook URL to `http://localhost:4545`.
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Integrations
|
||||
icon: false
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: GitHub & GitLab Setup
|
||||
description: Connect GitHub and GitLab repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise and self-hosted GitLab.
|
||||
navigation:
|
||||
icon: i-lucide-git-pull-request
|
||||
---
|
||||
|
||||
TaskView integrations allow you to connect GitHub or GitLab repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- TaskView API running
|
||||
- PostgreSQL database with migrations applied
|
||||
- `.env.taskview` file configured
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Migration
|
||||
|
||||
The migration creates the required tables (`tasks.integrations` and `tasks.integration_task_map`) automatically. The migration container handles this on startup - no manual steps needed. Just make sure you've run `docker compose up` and the migration container completed successfully.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate Encryption Key
|
||||
|
||||
Tokens are encrypted with AES-256-GCM. You need a 32-byte hex key (64 characters):
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Add it to `.env.taskview`:
|
||||
|
||||
```
|
||||
ENCRYPTION_KEY=<your-64-char-hex-key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Create GitHub OAuth App
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Click **"New OAuth App"**
|
||||
3. Fill in:
|
||||
- **Application name**: `TaskView Integrations` (or any name)
|
||||
- **Homepage URL**: `http://localhost:3000` (your frontend URL)
|
||||
- **Authorization callback URL**: `http://localhost:1401/module/integrations/oauth/github/callback`
|
||||
4. Click **"Register application"**
|
||||
5. Copy **Client ID** and generate a **Client Secret**
|
||||
|
||||
Add to `.env.taskview`:
|
||||
|
||||
```
|
||||
GITHUB_INTEGRATION_CLIENT_ID=<your-client-id>
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=<your-client-secret>
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/github/callback
|
||||
```
|
||||
|
||||
> **Note**: This is a separate OAuth App from the one used for login (`GITHUB_CLIENT_ID`). The integrations app requests `repo` scope, while the login app only requests `user:email`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Create GitLab OAuth App (optional)
|
||||
|
||||
1. Go to [GitLab Applications](https://gitlab.com/-/user_settings/applications)
|
||||
2. Click **"New application"**
|
||||
3. Fill in:
|
||||
- **Name**: `TaskView Integrations`
|
||||
- **Redirect URI**: `http://localhost:1401/module/integrations/oauth/gitlab/callback`
|
||||
- **Scopes**: check `api`
|
||||
4. Click **"Save application"**
|
||||
5. Copy **Application ID** and **Secret**
|
||||
|
||||
Add to `.env.taskview`:
|
||||
|
||||
```
|
||||
GITLAB_INTEGRATION_CLIENT_ID=<your-application-id>
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=<your-secret>
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Full `.env.taskview` Example
|
||||
|
||||
```env
|
||||
# ... existing vars ...
|
||||
|
||||
# Encryption (required for integrations)
|
||||
ENCRYPTION_KEY=a1b2c3d4e5f6... # 64 hex characters
|
||||
|
||||
# GitHub Integration OAuth
|
||||
GITHUB_INTEGRATION_CLIENT_ID=Iv1.abc123
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=secret_abc123
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/github/callback
|
||||
|
||||
# GitLab Integration OAuth (optional)
|
||||
GITLAB_INTEGRATION_CLIENT_ID=app_id_123
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage
|
||||
|
||||
1. Open a project in TaskView
|
||||
2. Right-click the project in the sidebar → **"Integrations"**
|
||||
3. Click **"Add Integration"**
|
||||
4. Choose **GitHub** or **GitLab** - you'll be redirected to authorize
|
||||
5. After authorization, select a repository from the list
|
||||
6. Done - the integration is active
|
||||
|
||||
You can toggle integrations on/off or delete them from the integrations page.
|
||||
|
||||
---
|
||||
|
||||
## Production Notes
|
||||
|
||||
- **Callback URLs**: Update to your production domain (e.g., `https://api.yourdomain.com/module/integrations/oauth/github/callback`)
|
||||
- **ENCRYPTION_KEY**: Store securely, never commit to git. If changed, existing encrypted tokens become unreadable
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab OAuth Apps for production with production callback URLs
|
||||
- **CORS**: Ensure your production frontend domain is in `CORS_ALLOWED_ORIGINS`
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Watch in the source code.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"GitHub integration OAuth is not configured"**
|
||||
→ `GITHUB_INTEGRATION_CLIENT_ID` or `GITHUB_INTEGRATION_CALLBACK_URL` is missing in `.env`
|
||||
|
||||
**"ENCRYPTION_KEY must be a 64-character hex string"**
|
||||
→ Generate a key: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||
|
||||
**OAuth redirects to wrong URL after callback**
|
||||
→ Check that `APP_URL` in `.env` matches your frontend URL (e.g., `http://localhost:3000`)
|
||||
|
||||
**Empty repo list after OAuth**
|
||||
→ The token may have expired or the integration record wasn't created. Check server logs and re-authorize.
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Configuration
|
||||
icon: false
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
title: Environment Variables
|
||||
description: Complete reference for TaskView environment variables - database connection, JWT authentication, OAuth providers, SMTP email, GitHub/GitLab integration, encryption, and CORS configuration for your self-hosted Docker deployment.
|
||||
navigation:
|
||||
icon: i-lucide-settings
|
||||
---
|
||||
|
||||
TaskView is configured through environment variables set in the `.env.taskview` file (or passed directly to the Docker container). This page documents every available variable.
|
||||
|
||||
## Database
|
||||
|
||||
These must match your PostgreSQL setup.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `DB_HOST` | Yes | - | Database hostname. Use `db` when running in Docker Compose. |
|
||||
| `DB_USER` | Yes | - | Database username |
|
||||
| `DB_PASSWORD` | Yes | - | Database password |
|
||||
| `DB_NAME` | Yes | - | Database name |
|
||||
| `DB_PORT` | No | `5432` | Database port |
|
||||
|
||||
## Application
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `APP_PORT` | No | `1401` | Port the API server listens on |
|
||||
| `APP_URL` | Yes | https://app.taskview.tech | Full URL of the web app (e.g. `https://tasks.company.com`). Used for OAuth redirects and email links. |
|
||||
|
||||
## Authentication
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `JWT_SIGN` | Yes | - | Secret key for signing JWT tokens. Use a long random string. |
|
||||
| `ACCESS_LIFE_TIME` | No | `1d` | How long access tokens are valid. Examples: `1h`, `1d`, `7d` |
|
||||
| `REFRESH_LIFE_TIME` | No | `2d` | How long refresh tokens are valid |
|
||||
| `JWT_ALG` | No | `HS256` | JWT signing algorithm |
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
|
||||
::
|
||||
|
||||
## SMTP (Email)
|
||||
|
||||
Required for password recovery, email confirmation, and invitation notifications. Without SMTP, these features won't work, but everything else functions normally.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SMTP_HOST` | No | - | SMTP server hostname |
|
||||
| `SMTP_PORT` | No | `465` | SMTP port |
|
||||
| `SMTP_USERNAME` | No | - | SMTP login |
|
||||
| `SMTP_PASSWORD` | No | - | SMTP password |
|
||||
| `SMTP_ENCRYPTION` | No | `ssl` | `ssl` or `tls` |
|
||||
| `SMTP_FROM_NAME` | No | `TaskView` | Sender name in emails |
|
||||
| `SMTP_FROM_EMAIL` | No | - | Sender email address |
|
||||
|
||||
## Encryption
|
||||
|
||||
Required for GitHub/GitLab integrations. OAuth tokens are encrypted at rest using AES-256-GCM.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | No | - | 32-byte hex string (64 characters). Required for integrations. |
|
||||
|
||||
Generate a key:
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="error"}
|
||||
If you change or lose the encryption key, all stored integration tokens become unreadable. You'll need to reconnect your GitHub/GitLab integrations.
|
||||
::
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
For connecting GitHub repositories. See [GitHub & GitLab Setup](/docs/integrations/setup) for a step-by-step guide.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `GITHUB_INTEGRATION_CLIENT_ID` | No | - | OAuth App client ID |
|
||||
| `GITHUB_INTEGRATION_CLIENT_SECRET` | No | - | OAuth App client secret |
|
||||
| `GITHUB_INTEGRATION_CALLBACK_URL` | No | - | OAuth callback URL |
|
||||
| `GITHUB_BASE_URL` | No | `https://github.com` | Override for GitHub Enterprise |
|
||||
| `GITHUB_API_URL` | No | `https://api.github.com` | Override for GitHub Enterprise API |
|
||||
|
||||
## GitLab Integration
|
||||
|
||||
For connecting GitLab repositories.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `GITLAB_INTEGRATION_CLIENT_ID` | No | - | OAuth App client ID |
|
||||
| `GITLAB_INTEGRATION_CLIENT_SECRET` | No | - | OAuth App client secret |
|
||||
| `GITLAB_INTEGRATION_CALLBACK_URL` | No | - | OAuth callback URL |
|
||||
| `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab |
|
||||
| `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API |
|
||||
|
||||
## Notifications
|
||||
|
||||
Optional configuration for real-time and push notification delivery.
|
||||
|
||||
### Centrifugo (real-time WebSocket notifications)
|
||||
|
||||
Required for in-app real-time notification delivery. Without Centrifugo, notifications are still saved to the database but will not appear instantly in the browser. Users will see them on the next page load.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `CENTRIFUGO_API_URL` | No | - | Internal Centrifugo API URL (e.g. `http://centrifugo:8000`) |
|
||||
| `CENTRIFUGO_API_KEY` | No | - | Centrifugo API key for server-to-server communication. Must match `http_api.key` in Centrifugo config. |
|
||||
| `CENTRIFUGO_TOKEN_SECRET` | No | - | Secret for generating client connection tokens (HMAC). Must match `client.token.hmac_secret_key` in Centrifugo config. |
|
||||
| `CENTRIFUGO_PUBLIC_URL` | No | - | Full WebSocket URL for browser clients (e.g. `wss://api.example.com/centrifugo/connection/websocket`) |
|
||||
|
||||
### Firebase Cloud Messaging (mobile push notifications)
|
||||
|
||||
Required for native push notifications on iOS and Android. Without Firebase, push notifications are silently skipped.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `FIREBASE_CREDENTIALS_PATH` | No | - | Path to Firebase service account JSON file (e.g. `./firebase-credentials.json`) |
|
||||
|
||||
### Centrifugo configuration file
|
||||
|
||||
Centrifugo v6 uses a nested JSON config. Create `centrifugo/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"client": {
|
||||
"allowed_origins": ["https://app.example.com"],
|
||||
"token": {
|
||||
"hmac_secret_key": "your_centrifugo_token_secret"
|
||||
}
|
||||
},
|
||||
"channel": {
|
||||
"namespaces": [
|
||||
{
|
||||
"name": "personal",
|
||||
"presence": false,
|
||||
"join_leave": false,
|
||||
"history_size": 0,
|
||||
"history_ttl": "0s"
|
||||
}
|
||||
]
|
||||
},
|
||||
"http_api": {
|
||||
"key": "your_centrifugo_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `http_api.key` must match `CENTRIFUGO_API_KEY` in `.env.taskview`
|
||||
- `client.token.hmac_secret_key` must match `CENTRIFUGO_TOKEN_SECRET` in `.env.taskview`
|
||||
- `client.allowed_origins` should list your frontend domain(s). Use `["*"]` only for development.
|
||||
|
||||
### Nginx WebSocket proxy
|
||||
|
||||
To serve Centrifugo through your existing HTTPS domain, add to your nginx server block:
|
||||
|
||||
```nginx
|
||||
location /centrifugo/ {
|
||||
proxy_pass http://localhost:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
```
|
||||
|
||||
Then set `CENTRIFUGO_PUBLIC_URL=wss://api.example.com/centrifugo/connection/websocket`.
|
||||
|
||||
::callout{icon="i-lucide-info" color="info"}
|
||||
Both Centrifugo and Firebase are optional. If not configured, their respective channels are skipped. Notifications are always persisted to the database.
|
||||
::
|
||||
|
||||
## Full example
|
||||
|
||||
Here's a complete `.env.taskview` file for a production deployment:
|
||||
|
||||
```env
|
||||
DB_HOST="db"
|
||||
DB_USER="taskview_db_user"
|
||||
DB_PASSWORD="password"
|
||||
DB_NAME="taskview"
|
||||
DB_PORT=5432
|
||||
APP_PORT=1401
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="secret"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
REFRESH_LIFE_TIME="9d"
|
||||
|
||||
SMTP_HOST=smtp
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_ENCRYPTION=tls
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
# Your domain
|
||||
APP_URL="https://app.taskview.tech"
|
||||
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
#You domain
|
||||
GOOGLE_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/google/callback"
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
GITHUB_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/github/callback"
|
||||
APPLE_CLIENT_ID=""
|
||||
APPLE_TEAM_ID=""
|
||||
APPLE_KEY_ID=""
|
||||
APPLE_KEY_LOCATION="/usr/src/app/AuthKey.p8"
|
||||
# Your domain
|
||||
APPLE_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/apple/callback"
|
||||
|
||||
#integrations
|
||||
GITHUB_INTEGRATION_CLIENT_ID=
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback
|
||||
|
||||
GITLAB_INTEGRATION_CLIENT_ID=
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback
|
||||
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Notifications (optional)
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
# CENTRIFUGO_API_URL=http://centrifugo:8000
|
||||
# CENTRIFUGO_API_KEY=your_centrifugo_api_key
|
||||
# CENTRIFUGO_TOKEN_SECRET=your_centrifugo_token_secret
|
||||
# CENTRIFUGO_PUBLIC_URL=wss://api.example.com/centrifugo/connection/websocket
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Configure authentication in TaskView - email/password, email/code, OAuth with GitHub, Google, and Apple Sign In. JWT session management, password recovery, and account deletion for your self-hosted instance.
|
||||
navigation:
|
||||
icon: i-lucide-lock
|
||||
---
|
||||
|
||||
TaskView supports multiple ways to sign in - email/password, email/code, GitHub, Google, and Apple. You can enable whichever methods make sense for your team.
|
||||
|
||||
## Email and password
|
||||
|
||||
This is the default method and works out of the box. Users register with an email and password, and log in the same way (email conformation is required).
|
||||
|
||||
If you have SMTP configured, users will receive a confirmation email after registration.
|
||||
Without SMTP, email confirmation is skipped and accounts should be activated manually.
|
||||
|
||||
### Password recovery
|
||||
|
||||
Requires SMTP. Users click "Forgot password" on the login screen, enter their email, and receive a reset link. Without SMTP configured, password recovery is not available - you'll need to reset passwords manually in the database.
|
||||
|
||||
## OAuth providers
|
||||
|
||||
TaskView can use external providers for login. This is separate from the integration OAuth (which is for connecting GitHub/GitLab repositories).
|
||||
|
||||
### GitHub login
|
||||
|
||||
Users click "Sign in with GitHub" and authorize the app. TaskView only requests the `user:email` scope - it reads the email to match or create an account.
|
||||
|
||||
To enable, you need a GitHub OAuth App (separate from the integrations one):
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Create a **New OAuth App**
|
||||
3. Set the callback URL to `{API_URL}/module/auth/provider/github/callback`
|
||||
|
||||
### Google login
|
||||
|
||||
Works the same way. Create credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), set the callback to `{API_URL}/module/auth/provider/google/callback`.
|
||||
|
||||
### Apple login
|
||||
|
||||
Available for users on Apple devices. Requires an Apple Developer account and Sign in with Apple configuration.
|
||||
|
||||
## Sessions
|
||||
|
||||
TaskView uses JWT tokens for session management:
|
||||
|
||||
- **Access token** - short-lived (default: 1 day), used for API requests
|
||||
- **Refresh token** - longer-lived (default: 2 days), used to get a new access token
|
||||
|
||||
When the access token expires, the app automatically uses the refresh token to get a new one. Users stay logged in as long as the refresh token is valid.
|
||||
|
||||
You can adjust token lifetimes with the `ACCESS_LIFE_TIME` and `REFRESH_LIFE_TIME` environment variables.
|
||||
|
||||
## Account deletion
|
||||
|
||||
Users can delete their own account from the account settings page. This is a two-step process - they request a deletion code (sent by email if SMTP is configured), then confirm. Account deletion removes all personal data (You cannot undo this action. You can only restore the data from a backup, if you have one.).
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Collaboration
|
||||
icon: false
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Team Members
|
||||
description: Invite team members to TaskView projects by email, assign tasks, manage access, and control visibility. Built-in collaboration tools with project ownership and role assignment for self-hosted project management.
|
||||
navigation:
|
||||
icon: i-lucide-users
|
||||
---
|
||||
|
||||
TaskView is built for teams. You can invite people to your projects, assign them tasks, and control what they can see and do through roles and permissions.
|
||||
|
||||
## Inviting members
|
||||
|
||||
1. Open a project and go to the **Collaboration** tab
|
||||
2. Enter the person's email address in the input field
|
||||
3. Click **Add**
|
||||
|
||||
The person needs to have a TaskView account with that email. If they don't have one yet, they'll need to register first (using the same email you invited them with).
|
||||
|
||||
Once added, they'll see the project in their sidebar and can start working immediately.
|
||||
|
||||
## Removing members
|
||||
|
||||
In the Collaboration tab, find the user and click the remove button. They'll lose access to the project instantly - all their tasks remain, but they can no longer view or edit anything in the project.
|
||||
|
||||
## Project owner
|
||||
|
||||
The person who creates a project is its **owner**. The owner has all permissions by default and can't be removed from the project. Ownership can't be transferred.
|
||||
|
||||
## What members can do
|
||||
|
||||
By default, new members don't have any permissions beyond viewing the project. You need to assign them a **role** that grants specific permissions. See [Roles and Permissions](/docs/collaboration/roles-and-permissions) for details.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Add people before assigning tasks** - you can only assign tasks to project members
|
||||
- **Use roles** - instead of giving each person individual permissions, create a few roles ("Developer", "Manager", "Viewer") and assign people to them
|
||||
- **Keep the member list clean** - remove people who are no longer working on the project. They'll still keep their own account, just won't have access to this project anymore.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Roles and Permissions
|
||||
description: Role-based access control (RBAC) in TaskView - 28 granular permissions for tasks, lists, Kanban boards, dependency graphs, team members, and GitHub/GitLab integrations. Per-project roles with server-side enforcement.
|
||||
navigation:
|
||||
icon: i-lucide-shield
|
||||
---
|
||||
|
||||
TaskView uses a role-based access control (RBAC) system. You create roles, assign permissions to those roles, and then assign roles to team members. This way you define once what a "Developer" or "Viewer" can do, and simply assign that role to new people.
|
||||
|
||||
## How it works
|
||||
|
||||

|
||||
|
||||
Each **project** has its own set of roles and permissions. A role in one project doesn't affect access in another.
|
||||
|
||||
The chain is simple:
|
||||
|
||||
**Permission** → assigned to → **Role** → assigned to → **User**
|
||||
|
||||
A user can have one or more roles per project. Their permissions are the sum of what that role allows.
|
||||
|
||||
## Creating roles
|
||||
|
||||
1. Go to the **Collaboration** tab in a project
|
||||
2. Open the **Roles** section
|
||||
3. Click **Add Role** and give it a name (like "Developer", "Designer", "Viewer")
|
||||
|
||||
## Assigning permissions to a role
|
||||
|
||||
After creating a role, toggle the permissions you want to grant. Permissions are grouped by area:
|
||||
|
||||
### Project permissions
|
||||
|
||||
| Permission | Key | What it allows |
|
||||
|---|---|---|
|
||||
| Delete project | `goal_can_delete` | Permanently delete the entire project |
|
||||
| Edit project | `goal_can_edit` | Rename the project, change color |
|
||||
| Manage users | `goal_can_manage_users` | Add/remove team members, assign roles |
|
||||
| Add lists | `goal_can_add_task_list` | Create new task lists in the project |
|
||||
| View lists | `goal_can_watch_content` | See the list of task lists (not the tasks inside) |
|
||||
|
||||
### List permissions
|
||||
|
||||
| Permission | Key | What it allows |
|
||||
|---|---|---|
|
||||
| Delete list | `component_can_delete` | Remove a task list and its contents |
|
||||
| Edit list | `component_can_edit` | Rename a task list |
|
||||
| View tasks | `component_can_watch_content` | See tasks inside a list - their title, status, deadlines, and times |
|
||||
| Add tasks | `component_can_add_tasks` | Create new tasks in a list |
|
||||
|
||||
### Task permissions
|
||||
|
||||
| Permission | Key | What it allows |
|
||||
|---|---|---|
|
||||
| Delete task | `task_can_delete` | Permanently remove a task |
|
||||
| Edit description | `task_can_edit_description` | Change the task title |
|
||||
| Edit status | `task_can_edit_status` | Toggle the completion checkbox |
|
||||
| Edit note | `task_can_edit_note` | Modify the rich-text note |
|
||||
| View note | `task_can_watch_note` | See the note editor |
|
||||
| Edit deadline | `task_can_edit_deadline` | Set or change start/end dates and times |
|
||||
| View details | `task_can_watch_details` | Open the task detail panel (works only in UI) |
|
||||
| View subtasks | `task_can_watch_subtasks` | See the subtasks section |
|
||||
| Add subtasks | `task_can_add_subtasks` | Create subtasks |
|
||||
| Edit tags | `task_can_edit_tags` | Add or remove tags on a task |
|
||||
| View tags | `task_can_watch_tags` | See which tags are attached |
|
||||
| View priority | `task_can_watch_priority` | See the task priority |
|
||||
| Edit priority | `task_can_edit_priority` | Change the task priority |
|
||||
| View history | `task_can_access_history` | See the change history of a task |
|
||||
| Restore history | `task_can_recovery_history` | Restore a task to a previous state |
|
||||
| Assign users | `task_can_assign_users` | Add or remove assignees |
|
||||
| View assignees | `task_can_watch_assigned_users` | See who is assigned to a task |
|
||||
|
||||
### Kanban permissions
|
||||
|
||||
| Permission | Key | What it allows |
|
||||
|---|---|---|
|
||||
| View Kanban | `kanban_can_view` | See the Kanban board |
|
||||
| Manage Kanban | `kanban_can_manage` | Create, edit, delete status columns and move tasks |
|
||||
|
||||
### Graph permissions
|
||||
|
||||
| Permission | Key | What it allows |
|
||||
|---|---|---|
|
||||
| View graph | `graph_can_view` | See the dependency graph |
|
||||
| Manage graph | `graph_can_manage` | Create and remove task dependencies |
|
||||
|
||||
### Integration permissions
|
||||
|
||||
| Permission | Key | What it allows |
|
||||
|---|---|---|
|
||||
| View integrations | `integrations_can_view` | See connected GitHub/GitLab integrations |
|
||||
| Manage integrations | `integrations_can_manage` | Add, remove, toggle, and sync integrations |
|
||||
|
||||
## Assigning roles to users
|
||||
|
||||
In the Collaboration tab, find the user and select a role from the dropdown. The permissions take effect immediately.
|
||||
|
||||
## The project owner
|
||||
|
||||
The project owner automatically has all permissions. You don't need to assign a role to the owner - they can always do everything.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Start with 2-3 roles** - "Admin" (everything), "Member" (create and edit), "Viewer" (read only). Add more specific roles only if you need them.
|
||||
- **Review permissions when something feels wrong** - if someone can't edit a task or see the Kanban board, it's almost always a missing permission on their role.
|
||||
- **Permissions are enforced on both client and server** - even if someone inspects the UI or calls the API directly, the server checks permissions before allowing any action.
|
||||
@@ -0,0 +1,2 @@
|
||||
title: FAQ
|
||||
icon: false
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
description: Common questions about TaskView - self-hosted open-source task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
|
||||
navigation:
|
||||
icon: i-lucide-circle-help
|
||||
---
|
||||
|
||||
Answers to the most common questions about TaskView.
|
||||
|
||||
## General
|
||||
|
||||
### What is TaskView?
|
||||
|
||||
TaskView is an source-available, self-hosted project and task management platform. It provides Kanban boards, dependency graphs, team collaboration with role-based access control, GitHub/GitLab integration, and a dashboard - all running on your own infrastructure.
|
||||
|
||||
### Is TaskView free?
|
||||
|
||||
Yes. TaskView Community Edition is free (see LICENSE) and source-available under the [license](https://github.com/Gimanh/taskview-community/blob/main/LICENSE.md).
|
||||
|
||||
### What is the difference between TaskView and SaaS tools like Trello or Asana?
|
||||
|
||||
TaskView is **self-hosted** - you run it on your own server. Your data never leaves your infrastructure. There are no subscriptions, no vendor lock-in, and no third-party access to your project data. You get full control over backups, updates, and security.
|
||||
|
||||
### Who is TaskView for?
|
||||
|
||||
TaskView is designed for teams and individuals who need task management with full data ownership. It works well for small teams, startups, freelancers, security-conscious organizations, and anyone who prefers self-hosted tools.
|
||||
|
||||
## Installation and Deployment
|
||||
|
||||
### How do I install TaskView?
|
||||
|
||||
TaskView runs as a set of Docker containers. You need Docker and Docker Compose installed, then create two environment files and a `docker-compose.yml`. The whole setup takes about 5 minutes. See the [Installation guide](/docs/getting-started/installation) for step-by-step instructions.
|
||||
|
||||
### What are the system requirements?
|
||||
|
||||
You need a server or local machine with Docker and Docker Compose. TaskView runs on any platform that supports Docker - Linux, macOS, or Windows. Minimum recommended: 1 CPU core, 1 GB RAM, 10 GB disk space.
|
||||
|
||||
### Can I run TaskView on a VPS?
|
||||
|
||||
Yes. TaskView works on any VPS provider (Hetzner, DigitalOcean, AWS EC2, Linode, etc.). Deploy with Docker Compose and put a reverse proxy (Nginx, Caddy, or Traefik) in front for SSL termination. See the [deployment guide](/docs/guides/deploy-vps-nginx) for a detailed walkthrough.
|
||||
|
||||
### How do I update TaskView?
|
||||
|
||||
Run `docker compose pull` followed by `docker compose up -d`. The migration container automatically applies any database changes on startup.
|
||||
|
||||
### Does TaskView support HTTPS?
|
||||
|
||||
TaskView itself serves HTTP. For HTTPS, use a reverse proxy like Nginx or Caddy in front of the TaskView containers to terminate SSL. This is the recommended production setup.
|
||||
|
||||
## Features
|
||||
|
||||
### Does TaskView have Kanban boards?
|
||||
|
||||
Yes. Each project has a Kanban board with customizable status columns. Drag and drop task cards between columns to update their status. See [Kanban Board](/docs/features/kanban) for details.
|
||||
|
||||
### Can I track task dependencies?
|
||||
|
||||
Yes. TaskView has an interactive dependency graph where you can link tasks and visualize the relationships. This helps identify bottlenecks and plan the order of work. See [Dependency Graph](/docs/features/graph).
|
||||
|
||||
### Does TaskView support subtasks?
|
||||
|
||||
Yes. Any task can have subtasks for breaking work into smaller steps. Subtasks are lightweight - they have a title and a completion state.
|
||||
|
||||
### Can I attach files to tasks?
|
||||
|
||||
Currently, TaskView does not support file attachments. You can add links and descriptions in the task notes using the rich text editor.
|
||||
|
||||
### Does TaskView have time tracking?
|
||||
|
||||
TaskView does not include built-in time tracking. It focuses on task management, Kanban workflows, and team collaboration.
|
||||
|
||||
### Does TaskView support financial tracking?
|
||||
|
||||
Yes. You can attach a monetary amount to any task and mark it as income or expense. This is useful for freelancers and teams that need basic budget tracking alongside task management.
|
||||
|
||||
## Team and Collaboration
|
||||
|
||||
### How do I invite team members?
|
||||
|
||||
Open a project, go to the Collaboration tab, and enter the person's email address. They need to have a TaskView account with that email. See [Team Members](/docs/collaboration/members).
|
||||
|
||||
### Does TaskView have role-based access control?
|
||||
|
||||
Yes. TaskView has a granular RBAC system with 28 permissions covering tasks, lists, Kanban, graphs, members, and integrations. You create roles, assign permissions, and assign roles to users. See [Roles and Permissions](/docs/collaboration/roles-and-permissions).
|
||||
|
||||
### Can different team members have different permissions?
|
||||
|
||||
Yes. Permissions are per-project. You can create roles like "Developer", "Manager", and "Viewer" with different permission sets, and assign them to team members independently in each project.
|
||||
|
||||
## Integrations
|
||||
|
||||
### Can I connect GitHub repositories?
|
||||
|
||||
Yes. TaskView can sync issues from GitHub repositories as tasks. You connect via OAuth, select a repository, and issues are imported and kept in sync via webhooks. See [GitHub & GitLab Setup](/docs/integrations/setup).
|
||||
|
||||
### Can I connect GitLab repositories?
|
||||
|
||||
Yes. GitLab integration works the same way as GitHub - OAuth authorization, repository selection, and webhook-based sync. Both cloud and self-hosted GitLab instances are supported.
|
||||
|
||||
### Does TaskView have an API?
|
||||
|
||||
Yes. TaskView has a [REST API](https://www.npmjs.com/package/taskview-api) that powers both the web app and mobile apps. The API uses JWT authentication and is fully documented in the source code.
|
||||
|
||||
## Security and Data
|
||||
|
||||
### Where is my data stored?
|
||||
|
||||
All data is stored in a PostgreSQL database on your server. The `pgdata` Docker volume contains the database files. No data is sent to external services.
|
||||
|
||||
### How do I back up my data?
|
||||
|
||||
Back up the PostgreSQL `pgdata` Docker volume. You can use standard PostgreSQL backup tools like `pg_dump` or volume-level backups depending on your infrastructure.
|
||||
|
||||
### Are OAuth tokens stored securely?
|
||||
|
||||
Yes. GitHub and GitLab integration tokens are encrypted at rest using AES-256-GCM with a key you provide via the `ENCRYPTION_KEY` environment variable.
|
||||
|
||||
## Mobile
|
||||
|
||||
### Does TaskView have mobile apps?
|
||||
|
||||
Yes. TaskView has Android and iOS apps built with Capacitor. They connect to your self-hosted server and sync your tasks, projects, and notifications.
|
||||
|
||||
- [iOS (App Store)](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
- [Android (Google Play)](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
|
||||
|
||||
### Can I use TaskView in a mobile browser?
|
||||
|
||||
Yes. The [web interface](https://app.taskview.tech) is responsive and works in mobile browsers, though the native apps provide a better experience.
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Guides
|
||||
icon: false
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
title: Deploy TaskView on a VPS with Nginx
|
||||
description: Step-by-step guide to deploy TaskView on a VPS with Nginx reverse proxy, SSL certificates via Let's Encrypt, and Docker Compose. Production-ready self-hosted setup.
|
||||
navigation:
|
||||
icon: i-lucide-server
|
||||
---
|
||||
|
||||
This guide walks you through deploying TaskView on a VPS with Nginx as a reverse proxy and free SSL certificates from Let's Encrypt.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A VPS with Ubuntu 22.04+ (Debian, CentOS, or any Linux distro with Docker support works too)
|
||||
- A domain name pointing to your server's IP address (e.g., `tasks.yourcompany.com` and `api.tasks.yourcompany.com`)
|
||||
- SSH access to the server
|
||||
|
||||
## Step 1: Install Docker
|
||||
|
||||
Connect to your server and install Docker:
|
||||
|
||||
```bash
|
||||
# Update packages
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Add your user to the docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Log out and back in for the group change to take effect
|
||||
```
|
||||
|
||||
Verify the installation:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
## Step 2: Set up TaskView
|
||||
|
||||
Follow the standard [Installation guide](/docs/getting-started/installation) to create your project directory, environment files, and `docker-compose.yml`.
|
||||
|
||||
Update your `.env.taskview` with production values:
|
||||
|
||||
```env
|
||||
APP_URL="https://tasks.yourcompany.com"
|
||||
```
|
||||
|
||||
Update `CORS_ALLOWED_ORIGINS` to include your production domain:
|
||||
|
||||
```env
|
||||
CORS_ALLOWED_ORIGINS="https://tasks.yourcompany.com"
|
||||
```
|
||||
|
||||
Start the containers:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Step 3: Install Nginx
|
||||
|
||||
```bash
|
||||
sudo apt install nginx -y
|
||||
```
|
||||
|
||||
## Step 4: Install Certbot and get SSL certificates
|
||||
|
||||
Install Certbot with the Nginx plugin to get free SSL certificates from Let's Encrypt:
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx -y
|
||||
```
|
||||
|
||||
## Step 5: Configure Nginx with HTTPS
|
||||
|
||||
Create a configuration file:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/taskview
|
||||
```
|
||||
|
||||
```nginx
|
||||
# TaskView Web App - redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name tasks.yourcompany.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name tasks.yourcompany.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/tasks.yourcompany.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/tasks.yourcompany.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8888;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# TaskView API - redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.tasks.yourcompany.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.tasks.yourcompany.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.tasks.yourcompany.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.tasks.yourcompany.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:1725;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/taskview /etc/nginx/sites-enabled/
|
||||
```
|
||||
|
||||
Get SSL certificates from Let's Encrypt using Certbot. Certbot will verify domain ownership and download the certificates referenced in the Nginx config:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d tasks.yourcompany.com -d api.tasks.yourcompany.com
|
||||
```
|
||||
|
||||
Test the configuration and restart Nginx:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
Certbot sets up automatic certificate renewal. Verify it works:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
## Step 6: Verify
|
||||
|
||||
Open `https://tasks.yourcompany.com` in your browser. You should see the TaskView login screen served over HTTPS.
|
||||
|
||||
## Automatic updates
|
||||
|
||||
Create a simple script to update TaskView:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /path/to/taskview
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
You can schedule this with cron if you want automatic updates, or run it manually when a new version is released.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**502 Bad Gateway**
|
||||
The TaskView containers aren't running. Check with `docker compose ps` and `docker compose logs`.
|
||||
|
||||
**SSL certificate errors**
|
||||
Make sure your domain's DNS A record points to your server's IP. Certbot needs to verify domain ownership.
|
||||
|
||||
**Can't connect to the API**
|
||||
Check that `CORS_ALLOWED_ORIGINS` in `.env.taskview` includes your production frontend URL with the `https://` prefix.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: TaskView for Freelancers
|
||||
description: How freelancers can use TaskView for project management, client work tracking, budget and income tracking, and task organization. Free self-hosted alternative to paid tools.
|
||||
navigation:
|
||||
icon: i-lucide-briefcase
|
||||
---
|
||||
|
||||
TaskView works well for freelancers who juggle multiple clients and need a simple way to track tasks, deadlines, and money - without paying for a SaaS subscription.
|
||||
|
||||
::callout{icon="i-lucide-scale" color="warning"}
|
||||
TaskView is source-available software. Before using it, please review the [license](https://github.com/Gimanh/taskview-community/blob/main/LICENSE.md) to make sure your use case is covered. Freelancers may use TaskView for their own task management (Internal Use), but providing access to clients or third parties is not permitted under the community license.
|
||||
::
|
||||
|
||||
## Why TaskView for freelancing
|
||||
|
||||
- **No per-seat costs** - it's free and self-hosted
|
||||
- **Financial tracking built in** - attach income/expense amounts to tasks
|
||||
- **One project per client** - keep work separated with independent permissions
|
||||
- **Full data ownership** - your client data stays on your server
|
||||
- **Deadline tracking** - the dashboard shows what's due today and what's coming up
|
||||
|
||||
## Setting up for client work
|
||||
|
||||
### One project per client
|
||||
|
||||
Create a separate project for each client. This gives you:
|
||||
|
||||
- Independent task lists (e.g., "Website", "Marketing", "Maintenance")
|
||||
- Client-specific tags ("urgent", "waiting-for-feedback", "billable")
|
||||
- Separate Kanban workflows per project
|
||||
- Clean separation when archiving completed client work
|
||||
|
||||
### Track income per task
|
||||
|
||||
Use the **financial amount** field on tasks to log what each piece of work is worth:
|
||||
|
||||
1. Open a task
|
||||
2. Set the financial amount
|
||||
3. Mark it as **income**
|
||||
|
||||
This gives you a per-project view of expected and completed revenue. While TaskView isn't accounting software, it's enough to see at a glance how much a project is worth.
|
||||
|
||||
### Use deadlines consistently
|
||||
|
||||
Set deadlines on every task that has a due date. The dashboard widgets - "Today's tasks" and "Upcoming deadlines" - become your daily planner across all clients.
|
||||
|
||||
### Kanban for workflow stages
|
||||
|
||||
Set up Kanban columns that match your freelance workflow:
|
||||
|
||||
- **Backlog** - ideas and future work
|
||||
- **To Do** - committed work for this week/sprint
|
||||
- **In Progress** - actively working on
|
||||
- **Waiting for Feedback** - sent to client, waiting for response
|
||||
- **Done** - completed and delivered
|
||||
|
||||
### Tags for cross-project filtering
|
||||
|
||||
Create consistent tags across projects:
|
||||
|
||||
- `billable` / `non-billable`
|
||||
- `urgent`
|
||||
- `recurring`
|
||||
- `blocked`
|
||||
|
||||
## Backing up your work
|
||||
|
||||
Since TaskView is self-hosted, you're responsible for backups. Set up a regular PostgreSQL backup (daily `pg_dump` to a separate location) so you never lose client data.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Outgoing Webhooks
|
||||
|
||||
## Overview
|
||||
|
||||
TaskView fires HTTP POST requests to external services when things happen with tasks.
|
||||
User registers a webhook URL, picks which events to listen to, gets a secret for signature verification.
|
||||
|
||||
No auth tokens, no OAuth, no API keys for consumers — just signed payloads.
|
||||
|
||||
## How it works
|
||||
|
||||
### Registration
|
||||
|
||||
User with `WEBHOOKS_CAN_MANAGE` permission goes to project settings (or a new "Webhooks" tab in the integrations panel).
|
||||
Fills in:
|
||||
- **URL** — where to send events (`https://ops.company.com/hooks/taskview`)
|
||||
- **Events** — checkboxes: `task.created`, `task.updated`, `task.completed`, `task.deleted`
|
||||
- **Description** (optional) — "Slack notifications", "PagerDuty alerts", etc.
|
||||
|
||||
On save, the server generates a **webhook secret** (random 32-byte hex, like we already do for GitHub/GitLab webhooks).
|
||||
The secret is shown to the user once. Stored encrypted in DB (same `encrypt()` we use for integration tokens).
|
||||
|
||||
### Payload
|
||||
|
||||
When an event fires, TaskView POSTs JSON to the registered URL:
|
||||
|
||||
```
|
||||
POST https://ops.company.com/hooks/taskview
|
||||
Content-Type: application/json
|
||||
X-TaskView-Event: task.completed
|
||||
X-TaskView-Signature: sha256=abc123...
|
||||
X-TaskView-Delivery: <uuid>
|
||||
|
||||
{
|
||||
"event": "task.completed",
|
||||
"timestamp": "2026-03-09T14:30:00Z",
|
||||
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"projectId": 42,
|
||||
"task": {
|
||||
"id": 123,
|
||||
"description": "Fix login bug",
|
||||
"complete": true,
|
||||
"goalListId": 5,
|
||||
"priorityId": 2,
|
||||
"statusId": 3,
|
||||
"assignedUsers": [1, 7],
|
||||
"tags": [10, 11]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Signature verification
|
||||
|
||||
The consumer verifies the request is really from TaskView using the shared secret:
|
||||
|
||||
```python
|
||||
import hmac, hashlib
|
||||
|
||||
def verify(payload_body, signature_header, secret):
|
||||
expected = 'sha256=' + hmac.new(
|
||||
secret.encode(), payload_body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature_header)
|
||||
```
|
||||
|
||||
Same approach GitHub uses. No OAuth needed — if the signature matches, it's from TaskView.
|
||||
This is simpler than API keys because the consumer doesn't need to store credentials for calling TaskView back.
|
||||
|
||||
### Why not API keys / OAuth?
|
||||
|
||||
Outgoing webhooks are **push-only**. TaskView pushes data to the consumer.
|
||||
The consumer doesn't call TaskView API — it just receives events.
|
||||
|
||||
If we later want consumers to call TaskView back (e.g. update a task from Slack), that's a separate feature (incoming API + API keys).
|
||||
Don't mix the two — outgoing webhooks are simple and should stay simple.
|
||||
|
||||
## Database
|
||||
|
||||
New table `tasks.webhooks`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE tasks.webhooks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
description TEXT,
|
||||
secret_encrypted TEXT NOT NULL,
|
||||
events TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_triggered_at TIMESTAMP,
|
||||
last_status_code INTEGER
|
||||
);
|
||||
```
|
||||
|
||||
`last_triggered_at` and `last_status_code` — so user can see if webhooks are actually working from the UI.
|
||||
|
||||
## Server-side architecture
|
||||
|
||||
### Where to fire events
|
||||
|
||||
`TasksManager` is where all task mutations go through. After a successful mutation, call the webhook dispatcher:
|
||||
|
||||
```
|
||||
TasksManager.addTaskNew() → dispatch('task.created', task)
|
||||
TasksManager.updateTask() → dispatch('task.updated', task)
|
||||
TasksManager.deleteTask() → dispatch('task.deleted', { id: taskId })
|
||||
TasksManager.completeTask() → dispatch('task.completed', task)
|
||||
```
|
||||
|
||||
### Dispatcher
|
||||
|
||||
```
|
||||
WebhookDispatcher.dispatch(event, payload, projectId):
|
||||
1. Fetch active webhooks for projectId where events[] contains event
|
||||
2. For each webhook:
|
||||
- Build JSON payload
|
||||
- Sign with HMAC-SHA256 using decrypted secret
|
||||
- POST async (fire-and-forget with .catch())
|
||||
- Update last_triggered_at and last_status_code
|
||||
```
|
||||
|
||||
**Important**: fire-and-forget. Never await webhook delivery in the request path.
|
||||
If the external service is down, the task still gets created instantly.
|
||||
|
||||
### Retries
|
||||
|
||||
Simple retry: 3 attempts with delays of 5s, 30s, 5min.
|
||||
Use `setTimeout` — no need for a job queue at this scale.
|
||||
If all 3 fail, log it and move on. User can see `last_status_code` in the UI.
|
||||
|
||||
No dead letter queue, no persistent retry storage. Keep it simple.
|
||||
If someone needs guaranteed delivery, they should use a proper message broker on their end.
|
||||
|
||||
## Permissions
|
||||
|
||||
Two new permissions (same pattern as integrations):
|
||||
|
||||
- `WEBHOOKS_CAN_MANAGE` — create, edit, delete, toggle webhooks
|
||||
- `WEBHOOKS_CAN_VIEW` — see registered webhooks and their status
|
||||
|
||||
Owner gets both automatically (like all other permissions).
|
||||
|
||||
## UI
|
||||
|
||||
Add a "Webhooks" tab in the integrations panel (or a separate section).
|
||||
|
||||
List view shows:
|
||||
- URL (truncated)
|
||||
- Description
|
||||
- Events (badges)
|
||||
- Status: green dot if last_status_code is 2xx, red if 4xx/5xx, gray if never triggered
|
||||
- Toggle switch (active/inactive)
|
||||
- Delete button
|
||||
|
||||
Add form:
|
||||
- URL input
|
||||
- Event checkboxes
|
||||
- Description input
|
||||
- On save: show the secret once in a modal ("copy this, you won't see it again")
|
||||
|
||||
## Events to support (Phase 1)
|
||||
|
||||
| Event | When |
|
||||
|-------|------|
|
||||
| `task.created` | New task added |
|
||||
| `task.updated` | Task title, note, deadline, priority, status, list changed |
|
||||
| `task.completed` | Task marked as complete or reopened |
|
||||
| `task.deleted` | Task deleted |
|
||||
|
||||
Phase 2 (later): `list.created`, `list.deleted`, `member.added`, `member.removed`
|
||||
|
||||
## What this does NOT cover
|
||||
|
||||
- **Incoming API** — external services calling TaskView to create/update tasks. That's a separate feature with API keys and rate limiting.
|
||||
- **Custom fields** — separate RFC, much bigger scope.
|
||||
- **Plugin UI** — separate RFC, needs sandboxing and component API.
|
||||
- **Real-time / WebSocket** — separate feature, different use case.
|
||||
|
||||
## Example integrations
|
||||
|
||||
### Slack notification
|
||||
Register webhook for `task.created`. Consumer receives payload, formats a Slack message, posts to Slack API.
|
||||
5 lines of code in any language.
|
||||
|
||||
### Zapier
|
||||
Register webhook URL from Zapier's "Catch Hook" trigger. Zapier handles the rest — send to Google Sheets, email, whatever.
|
||||
Zero code.
|
||||
|
||||
### Custom monitoring
|
||||
Register webhook for `task.completed`. Consumer checks if task has "incident" tag, updates status page.
|
||||
Small Python/Node script.
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.20.7",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
@@ -25,6 +25,11 @@
|
||||
"@types/node": "^18.19.119",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"pg": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=8.0.0"
|
||||
|
||||
Generated
+1381
-92
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
import TvApiBase from "./base";
|
||||
import type { AppResponse } from "./base.types";
|
||||
import type {
|
||||
IntegrationArgAdd,
|
||||
IntegrationArgSelectRepo,
|
||||
IntegrationArgToggle,
|
||||
IntegrationResponseAdd,
|
||||
IntegrationResponseDelete,
|
||||
IntegrationResponseFetch,
|
||||
IntegrationResponseRepos,
|
||||
IntegrationResponseSelectRepo,
|
||||
IntegrationResponseSync,
|
||||
IntegrationResponseToggle,
|
||||
} from "./integrations.types";
|
||||
|
||||
export default class TvIntegrationsApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/integrations';
|
||||
|
||||
public async fetchIntegrations(projectId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<IntegrationResponseFetch>>(`${this.moduleUrl}`, {
|
||||
params: { projectId },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async createIntegration(data: IntegrationArgAdd) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<IntegrationResponseAdd>>(`${this.moduleUrl}`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteIntegration(id: number) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<IntegrationResponseDelete>>(`${this.moduleUrl}`, {
|
||||
data: { id },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async toggleIntegration(data: IntegrationArgToggle) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<IntegrationResponseToggle>>(`${this.moduleUrl}/toggle`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchRepos(integrationId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<IntegrationResponseRepos>>(`${this.moduleUrl}/repos`, {
|
||||
params: { integrationId },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async selectRepo(data: IntegrationArgSelectRepo) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<IntegrationResponseSelectRepo>>(`${this.moduleUrl}/select-repo`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async syncIntegration(integrationId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<IntegrationResponseSync>>(`${this.moduleUrl}/sync`, { integrationId })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type IntegrationProvider = 'github' | 'gitlab';
|
||||
|
||||
export type IntegrationItem = {
|
||||
id: number;
|
||||
provider: IntegrationProvider;
|
||||
repoFullName: string | null;
|
||||
repoExternalId: string | null;
|
||||
projectId: number;
|
||||
isActive: boolean;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type IntegrationArgAdd = {
|
||||
provider: IntegrationProvider;
|
||||
repoFullName: string;
|
||||
projectId: number;
|
||||
};
|
||||
|
||||
export type IntegrationArgDelete = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type IntegrationArgToggle = {
|
||||
id: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type IntegrationArgSelectRepo = {
|
||||
integrationId: number;
|
||||
repoFullName: string;
|
||||
repoExternalId: string;
|
||||
};
|
||||
|
||||
export type RepoItem = {
|
||||
id: number;
|
||||
fullName: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
description: string | null;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type IntegrationResponseAdd = IntegrationItem | null;
|
||||
export type IntegrationResponseFetch = IntegrationItem[];
|
||||
export type IntegrationResponseToggle = IntegrationItem | null;
|
||||
export type IntegrationResponseDelete = boolean;
|
||||
export type IntegrationResponseSelectRepo = IntegrationItem | null;
|
||||
export type IntegrationResponseRepos = RepoItem[];
|
||||
export type IntegrationResponseSync = { synced: number };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user