Compare commits

...

34 Commits

Author SHA1 Message Date
Gimanh 3aff4c77f2 Merge pull request #44 from Gimanh/chore/version-1-40
chore: new version 1.41
2026-03-30 00:20:56 +02:00
Nikolai Giman 1d6af3ab7d chore: new version 1.41 2026-03-30 00:20:34 +02:00
Gimanh 5600d261bc Merge pull request #43 from Gimanh/feat/api-tokens
feat: api-tokens
2026-03-29 22:42:55 +02:00
Nikolai Giman 35776d9eb5 fix: filter only allowed projects for api-tokens 2026-03-29 15:55:09 +02:00
Nikolai Giman 9f0cfccdc6 feat: api-tokens 2026-03-29 14:29:31 +02:00
Gimanh ba17eff713 Merge pull request #42 from Gimanh/fix/migration
fix: migration
2026-03-23 22:13:07 +01:00
Nikolai Giman 08ee2af865 fix: migration 2026-03-23 22:12:44 +01:00
Gimanh 4412ba1adf Merge pull request #40 from Gimanh/ver/1-30
chore: version 1.32.0
2026-03-22 23:30:20 +01:00
Nikolai Giman 2ad29b77f9 chore: version 1.32.0 2026-03-22 23:29:56 +01:00
Gimanh 9307ab5e45 Merge pull request #39 from Gimanh/feat/notifications
Feat/notifications
2026-03-22 20:10:58 +01:00
Nikolai Giman 2f4e84b54b wip: updater 2026-03-22 20:09:50 +01:00
Nikolai Giman c403673f4d wip: notifications 2026-03-22 20:01:05 +01:00
Nikolai Giman 5c4859743e wip: exclude initiator from notifications 2026-03-15 23:45:13 +01:00
Nikolai Giman 0bf0052909 wip: notifications, detect user assign and send 2026-03-15 23:38:55 +01:00
Nikolai Giman 0e1455b947 wip: notifications 2026-03-15 22:53:51 +01:00
Gimanh 63e2481b9d Merge pull request #38 from Gimanh/fix/integrations
Fix/integrations
2026-03-15 14:43:45 +01:00
Nikolai Giman ade7c5d007 fix: add link to integration task source 2026-03-15 14:29:24 +01:00
Nikolai Giman fc3d03f932 fix: filter issue 2026-03-13 22:35:18 +01:00
Gimanh 9de2b64acf Merge pull request #37 from Gimanh/chore/docs
chore: docs
2026-03-13 21:58:48 +01:00
Nikolai Giman ef206635f6 chore: docs 2026-03-13 21:56:19 +01:00
Gimanh b8b8481fb4 Merge pull request #36 from Gimanh/chore/version-1-24
chore: version up
2026-03-08 22:33:36 +01:00
Nikolai Giman 71f3385842 chore: version up 2026-03-08 22:32:59 +01:00
Gimanh cf35720093 Merge pull request #35 from Gimanh/fix/ui-fixes
fix: ui improvements
2026-03-08 22:15:40 +01:00
Nikolai Giman 3ef150add9 fix: ui improvements 2026-03-08 22:15:11 +01:00
Gimanh 626b532d2e Merge pull request #33 from Gimanh/feat/date-new-placeholders
feat: date new placeholders
2026-03-08 21:12:19 +01:00
Nikolai Giman e5dfd74967 feat: date new placeholders 2026-03-08 21:11:55 +01:00
Gimanh cb8f02af4f Merge pull request #32 from Gimanh/fix/graph-permissions-for-api
fix: handle graph permissions in the API
2026-03-08 20:34:57 +01:00
Nikolai Giman 996dd11af9 fix: handle graph permissions in the API 2026-03-08 20:33:48 +01:00
Gimanh 9a33837ffd Merge pull request #31 from Gimanh/fix/main-screen-checkbox-disabled
fix: read correct goal permissions for task checkbox
2026-03-08 20:31:29 +01:00
Nikolai Giman 7b24da0688 fix: read correct goal permissions for task checkbox 2026-03-08 18:39:50 +01:00
Gimanh 009d252651 Merge pull request #30 from Gimanh/fix/lists-load-for-taskdetail
fix: lists load for task details
2026-03-08 18:11:59 +01:00
Nikolai Giman 3c7733e5b0 fix: lists load for task details 2026-03-08 18:11:06 +01:00
Gimanh 2fa8bd5227 Merge pull request #29 from Gimanh/feat/git-integration
feat: integrations github & gitlab
2026-03-08 17:56:15 +01:00
Nikolai Giman 37039278c4 feat: integrations 2026-03-08 16:30:00 +01:00
245 changed files with 14293 additions and 674 deletions
+33 -1
View File
@@ -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
+6
View File
@@ -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
+7 -2
View File
@@ -1,11 +1,12 @@
{
"name": "taskview-ce-api-server",
"version": "1.20.7",
"version": "1.41.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",
@@ -27,6 +28,7 @@
"@types/passport-apple": "^2.0.3",
"@types/pg": "^8.15.5",
"@types/semver": "^7.5.8",
"@types/ua-parser-js": "^0.7.39",
"aws-sdk": "^2.1691.0",
"mock-aws-s3": "^4.0.2",
"nock": "^13.5.5",
@@ -54,6 +56,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,11 +64,13 @@
"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",
"taskview-db-schemas": "workspace:^",
"terser": "^5.36.0",
"ua-parser-js": "^2.0.9",
"zod": "^3.23.8"
},
"engines": {
+12 -2
View File
@@ -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();
});
}
}
+31
View File
@@ -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';
@@ -22,10 +24,15 @@ export class AppUser {
public readonly tagsManager: TagsManager;
public readonly authManager: AuthManager;
private hasActiveToken: boolean = false;
private apiTokenAuth: boolean = false;
private tokenPermissions?: string[];
private allowedGoalIds?: number[];
private userDataFromDb?: UserDbRecord;
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 +47,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 {
@@ -69,4 +78,26 @@ export class AppUser {
isBlocked(): boolean {
return this.userDataFromDb?.block !== 0;
}
/**
* Use it for API token authentication.
* @param permissions Permissions that the API token has
*/
setApiTokenAuth(permissions: string[], goalIds: number[]) {
this.apiTokenAuth = true;
this.tokenPermissions = permissions;
this.allowedGoalIds = goalIds;
}
isApiTokenAuth(): boolean {
return this.apiTokenAuth;
}
getAllowedGoalIds(): number[] | undefined {
return this.allowedGoalIds;
}
getTokenPermissions(): string[] | undefined {
return this.tokenPermissions;
}
}
+77
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
export interface Dispatcher {
register(): void;
registerWorkers(): Promise<void>;
}
+41
View File
@@ -0,0 +1,41 @@
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 };
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
'collaboration.rolesChanged': { goalId: number; collaborationUserId: 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();
+14 -3
View File
@@ -39,14 +39,25 @@ export class GoalPermissionsFetcher {
if (!goalId) { return new GoalPermissionsChecker([]); }
//Token authentication check
const allowedGoalIds = this.user.getAllowedGoalIds();
if (allowedGoalIds && allowedGoalIds.length > 0 && !allowedGoalIds.includes(goalId)) {
return new GoalPermissionsChecker([]);
}
if (this.isCacheValid(goalId)) {
return this.checkerCache[goalId].checker;
}
let permissions = await this.goalPermissionsRepository.fetchPermissionsForGoal(goalId, this.user);
const tokenPerms = this.user.getTokenPermissions();
if (tokenPerms && tokenPerms.length > 0) {
permissions = permissions.filter(p => tokenPerms.includes(p.permissionName));
}
this.checkerCache[goalId] = {
checker: new GoalPermissionsChecker(
await this.goalPermissionsRepository.fetchPermissionsForGoal(goalId, this.user)
),
checker: new GoalPermissionsChecker(permissions),
timestamp: performance.now(),
};
+53
View File
@@ -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}"`);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { startJobQueue } from './JobQueue';
import type { Dispatcher } from './Dispatcher';
import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher';
import { RealtimeDispatcher } from '../tv-modules/realtime/RealtimeDispatcher';
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
];
export function registerAllEventHandlers() {
dispatchers.forEach((d) => d.register());
}
export async function startAllWorkers() {
await startJobQueue();
for (const d of dispatchers) {
await d.registerWorkers();
}
}
+28 -3
View File
@@ -2,20 +2,45 @@ import type { NextFunction, Request, Response } from 'express';
import { AppUser } from '../core/AppUser';
import { $logger } from '../modules/logget';
import AuthController from '../tv-modules/auth/AuthController';
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
const token = req.headers['authorization']?.split(' ')[1];
if (token && token.startsWith(TOKEN_PREFIX)) {
const record = await getApiTokensManager().validateToken(token);
if (record) {
const authManager = new AppUser().authManager;
const userData = await authManager.repository.fetchUserById(record.userId);
if (userData && userData.block === 0) {
req.appUser = new AppUser({
id: 0,
userData: { id: userData.id, login: userData.login, email: userData.email },
});
req.appUser.setUserDataFromDb(userData);
req.appUser.setHasActiveToken(true);
req.appUser.setApiTokenAuth(record.allowedPermissions, record.allowedGoalIds);
} else {
req.appUser = new AppUser();
}
} else {
req.appUser = new AppUser();
}
return next();
}
if (token) {
const userPayload = await AuthController.validateTokens(token);
if (userPayload) {
req.appUser = new AppUser(userPayload);
try {
const [tokens, userData] = await Promise.allSettled([
req.appUser.authManager.jwtStorage.fetchTokens(userPayload.id),
const [sessionActive, userData] = await Promise.allSettled([
req.appUser.authManager.sessionStorage.isSessionActive(userPayload.id),
req.appUser.authManager.repository.fetchUserById(userPayload.userData.id),
]);
if (tokens.status === 'fulfilled') {
if (sessionActive.status === 'fulfilled' && sessionActive.value) {
req.appUser.setHasActiveToken(true);
}
+124
View File
@@ -305,5 +305,129 @@
"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"
]
},
"30": {
"version": "1.27.0",
"name": "Release 1.27.0",
"releaseDate": "20260322",
"scripts": [
"/1.27.0/0.1.27.0.sql"
],
"description": [
"Added webhooks and webhook_deliveries tables"
]
},
"31": {
"version": "1.28.0",
"name": "Fix missing 1.25.0 migrations",
"releaseDate": "20260323",
"scripts": [
"/1.28.0/0.fix-missing-1.25.0-migrations.sql"
],
"description": [
"Fix: apply missing migrations from 1.25.0 - convert TIMETZ to TIME and add timezone column to device_tokens"
]
},
"32": {
"version": "1.29.0",
"name": "Release 1.29.0",
"releaseDate": "20260328",
"scripts": [
"/1.29.0/0.1.29.0.sql"
],
"description": [
"Added api_tokens table for personal access tokens"
]
},
"33": {
"version": "1.30.0",
"name": "Release 1.30.0",
"releaseDate": "20260328",
"scripts": [
"/1.30.0/0.1.30.0.sql"
],
"description": [
"Added allowed_goal_ids column to api_tokens for project-scoped tokens"
]
},
"34": {
"version": "1.31.0",
"name": "Release 1.31.0",
"releaseDate": "20260328",
"scripts": [
"/1.31.0/0.1.31.0.sql",
"/1.31.0/all-triggers.sql"
],
"description": [
"Remove JWT storage from user_tokens, add session metadata (device_name, user_agent, last_used_at)",
"Add trigger to remove user from task assignees when removed from project collaboration"
]
}
}
@@ -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);
@@ -0,0 +1,10 @@
-- Fix: these migrations were missing from 1.25.0 migrate.json scripts list
-- 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';
ALTER TABLE tasks.device_tokens
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS tv_auth.api_tokens (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
last_used_at TIMESTAMP,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON tv_auth.api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON tv_auth.api_tokens(user_id);
@@ -0,0 +1 @@
ALTER TABLE tv_auth.api_tokens ADD COLUMN IF NOT EXISTS allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}';
@@ -0,0 +1,6 @@
ALTER TABLE tv_auth.user_tokens
DROP COLUMN IF EXISTS access_token,
DROP COLUMN IF EXISTS refresh_token,
ADD COLUMN IF NOT EXISTS device_name varchar(200),
ADD COLUMN IF NOT EXISTS user_agent text,
ADD COLUMN IF NOT EXISTS last_used_at timestamp;
@@ -0,0 +1,610 @@
--1.
--Trigger set previous version
create or replace function app.trigger_set_previous_version()
returns trigger as
$date_complete$
begin
new.prev_version = old.version;
return new;
end;
$date_complete$
language plpgsql;
drop trigger if exists trigger_set_previous_version on app.version;
create trigger trigger_set_previous_version
before insert
on app.version
for each row
execute procedure app.trigger_set_previous_version();
--2.
--Trigger for adding owner for taskList from goal
create or replace function tasks.trigger_set_owner_for_component()
returns trigger as
$date_complete$
begin
new.owner = (select owner from tasks.goals where id = new.goal_id);
return new;
end;
$date_complete$
language plpgsql;
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
create trigger trigger_set_owner_for_component
before insert
on tasks.goal_lists
for each row
execute procedure tasks.trigger_set_owner_for_component();
--3.
--Trigger for updating date_complete for task
create or replace function tasks.update_date_complete()
returns trigger as
$date_complete$
begin
if new.complete != old.complete
then
if new.complete = true
then
update tasks.tasks set date_complete = now() where id = old.id;
else
update tasks.tasks set date_complete = null where id = old.id;
end if;
end if;
return new;
end;
$date_complete$
language plpgsql;
drop trigger if exists tr_update_date_complete on tasks.tasks;
create trigger tr_update_date_complete
after update
on tasks.tasks
for each row
execute procedure tasks.update_date_complete();
--4.
-- Delete user from collaboration if not assigned to any goal
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
returns trigger as $$
declare
count int;
begin
if not exists (
select 1
from collaboration.users_to_goals
where user_id = old.user_id
limit 1
) then
delete from collaboration.users where id = old.user_id;
end if;
return old;
end;
$$ language plpgsql;
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
create trigger trigger_delete_user_if_not_assigned_to_goal
after delete
on collaboration.users_to_goals
for each row
execute function collaboration.delete_user_if_not_assigned_to_goal();
--5.
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
create or replace function tasks.check_task_graph_relation_goal()
returns trigger as $$
declare
from_goal int;
to_goal int;
begin
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
if from_goal is null or to_goal is null then
raise exception 'Invalid task reference in relation';
end if;
if from_goal <> to_goal then
raise exception 'Relation goal_id must match both tasks'' goal_id';
end if;
new.goal_id := from_goal;
return new;
end;
$$ language plpgsql;
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
create trigger trigger_task_relation_goal
before insert or update on tasks.task_relations
for each row execute function tasks.check_task_graph_relation_goal();
--6.
--Trigger for logging changes in taskList to history table
create or replace function tasks.log_changes_tasks_goal_lists()
returns trigger as
$body$
begin
if tg_op = 'DELETE' then
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
return old;
elseif tg_op = 'UPDATE' then
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
new.edit_date = now();
return new;
end if;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
create trigger trigger_log_changes_tasks_goal_lists
before update or delete
on tasks.goal_lists
for each row
execute procedure tasks.log_changes_tasks_goal_lists();
--7.
--Trigger for logging changes in goal to history table
create or replace function tasks.log_changes_tasks_goals()
returns trigger as
$body$
begin
if tg_op = 'DELETE' then
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
return old;
elseif tg_op = 'UPDATE' then
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
new.edit_date = now();
return new;
end if;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
create trigger trigger_log_changes_tasks_goals
before update or delete
on tasks.goals
for each row
execute procedure tasks.log_changes_tasks_goals();
--8.
--Trigger for logging changes in task to history table
create or replace function tasks.log_changes_tasks_tasks()
returns trigger as
$body$
begin
if tg_op = 'DELETE' then
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
return old;
elseif tg_op = 'UPDATE' then
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
new.edit_date = now();
return new;
end if;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
create trigger trigger_log_changes_tasks_tasks
before update or delete
on tasks.tasks
for each row
execute procedure tasks.log_changes_tasks_tasks();
--9.
--Trigger for setting goal_id default for task
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
RETURNS TRIGGER AS
$$
DECLARE
goal_id INT;
BEGIN
SELECT gl.goal_id
INTO goal_id
FROM tasks.goal_lists gl
WHERE gl.id = NEW.goal_list_id;
IF goal_id IS NOT NULL THEN
NEW.goal_id := goal_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
CREATE TRIGGER before_insert_set_goal_id_for_task
BEFORE INSERT OR UPDATE
ON tasks.tasks
FOR EACH ROW
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
--10.
--Trigger for adding default roles and permissions for goal
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
RETURNS TRIGGER AS
$$
DECLARE
editor_role_id INTEGER;
executor_role_id INTEGER;
BEGIN
-- 1. Create role "editor"
INSERT INTO collaboration.roles (name, goal_id)
VALUES ('editor', NEW.id)
RETURNING id INTO editor_role_id;
-- 2. Create role "executor"
INSERT INTO collaboration.roles (name, goal_id)
VALUES ('executor', NEW.id)
RETURNING id INTO executor_role_id;
-- 3. Add permissions for role "editor"
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
SELECT editor_role_id, id
FROM tv_auth.permissions
WHERE name IN (
'goal_can_watch_content',
'goal_can_edit',
'goal_can_add_task_list',
'goal_can_manage_users',
'component_can_watch_content',
'component_can_edit',
'component_can_delete',
'component_can_add_tasks',
'task_can_edit_deadline',
'task_can_watch_subtasks',
'task_can_watch_note',
'task_can_recovery_history',
'task_can_watch_assigned_users',
'task_can_edit_priority',
'task_can_delete',
'task_can_watch_details',
'task_can_assign_users',
'task_can_add_subtasks',
'task_can_watch_tags',
'task_can_watch_priority',
'task_can_access_history',
'task_can_edit_tags',
'task_can_edit_description',
'task_can_edit_status',
'task_can_edit_note',
'kanban_can_manage',
'kanban_can_view',
'graph_can_manage',
'graph_can_view'
);
-- 4. Add permissions for role "viewver"
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
SELECT executor_role_id, id
FROM tv_auth.permissions
WHERE name IN (
'goal_can_watch_content',
'component_can_watch_content',
'component_can_add_tasks',
'task_can_watch_subtasks',
'task_can_watch_note',
'task_can_watch_assigned_users',
'task_can_watch_details',
'task_can_add_subtasks',
'task_can_watch_tags',
'task_can_watch_priority'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists add_roles_after_insert on tasks.goals;
CREATE TRIGGER add_roles_after_insert
AFTER INSERT
ON tasks.goals
FOR EACH ROW
EXECUTE FUNCTION tasks.add_roles_and_permissions();
--11.
--Trigger for adjusting start and end dates for task
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
RETURNS TRIGGER AS
$$
DECLARE
start_timestamp TIMESTAMPTZ;
end_timestamp TIMESTAMPTZ;
BEGIN
-- If start_date is NULL, then start_time should be NULL
IF NEW.start_date IS NULL THEN
NEW.start_time := NULL;
END IF;
-- If end_date is NULL, then end_time should be NULL
IF NEW.end_date IS NULL THEN
NEW.end_time := NULL;
END IF;
-- If both dates are set
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
-- Adjust dates
IF NEW.start_date > NEW.end_date THEN
-- If start_date is greater than end_date, set end_date to start_date
NEW.end_date := NEW.start_date;
-- end_time remains unchanged
ELSIF NEW.end_date < NEW.start_date THEN
-- If end_date is less than start_date, set start_date to end_date
NEW.start_date := NEW.end_date;
-- start_time remains unchanged
END IF;
-- Prepare timestamps for comparison
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
IF start_timestamp > end_timestamp THEN
NEW.end_date := NEW.start_date;
-- Assign end_time only if start_time is not NULL
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
NEW.end_time := NEW.start_time;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
CREATE TRIGGER adjust_dates_and_times_trigger
BEFORE INSERT OR UPDATE
ON tasks.tasks
FOR EACH ROW
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
--12.
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
create or replace function tasks.add_self_to_collaboration()
returns trigger as $$
DECLARE
owner_email TEXT;
BEGIN
select email into owner_email
from tv_auth.users
where id = NEW.owner;
if owner_email is not null then
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
end if;
return NEW;
END;
$$ language plpgsql;
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
create trigger add_selt_to_collaboration_trg
after insert on tasks.goals
for each row
execute function tasks.add_self_to_collaboration();
--13.
--Trigger for adding default kanban columns for new goal
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
RETURNS TRIGGER AS $$
BEGIN
-- Add default columns for new goal
INSERT INTO tasks.statuses (name, goal_id, view_order)
VALUES
('TODO', NEW.id, 1),
('In Progress', NEW.id, 2),
('Done', NEW.id, 3);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
CREATE TRIGGER kanban_add_default_columns_trg
AFTER INSERT ON tasks.goals
FOR EACH ROW
EXECUTE FUNCTION tasks.kanban_add_default_columns();
--14.
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
RETURNS TRIGGER AS $$
BEGIN
-- Check if there is a record in tasks.statuses with the same goal_id
IF NOT EXISTS (
SELECT 1 FROM tasks.statuses s
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
) THEN
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists enforce_task_status_goal on tasks.tasks;
CREATE TRIGGER enforce_task_status_goal
BEFORE INSERT OR UPDATE ON tasks.tasks
FOR EACH ROW
WHEN (NEW.status_id IS NOT NULL)
EXECUTE FUNCTION tasks.check_task_status_goal();
--15.
--Trigger for setting default orders value for task
CREATE OR REPLACE FUNCTION tasks.set_order_value()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.task_order IS NULL THEN
NEW.task_order := NEW.id;
END IF;
IF NEW.kanban_order IS NULL THEN
NEW.kanban_order := NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists set_order_trigger on tasks.tasks;
CREATE TRIGGER set_order_trigger
BEFORE INSERT ON tasks.tasks
FOR EACH ROW
EXECUTE FUNCTION tasks.set_order_value();
--16.
--Trigger for setting default view order for new status
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
RETURNS TRIGGER AS $$
DECLARE
new_view_order INT;
BEGIN
-- Determine the next view_order for the given goal_id
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
FROM tasks.statuses
WHERE goal_id = NEW.goal_id;
-- Assign the calculated value to the view_order field
NEW.view_order := new_view_order;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists set_default_status_view_order on tasks.statuses;
CREATE TRIGGER set_default_status_view_order
BEFORE INSERT ON tasks.statuses
FOR EACH ROW
EXECUTE FUNCTION tasks.status_set_default_view_order();
--17.
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
RETURNS TRIGGER AS $$
DECLARE
user_exists BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1
FROM tasks.tasks tt
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
) INTO user_exists;
IF NOT user_exists THEN
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
BEFORE INSERT ON tasks_auth.task_assignee
FOR EACH ROW
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
--18.
--Trigger for adding owner for task, extend owner from goal or taskList
--delete old function with wrong name
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
drop function if exists tasks.trigger_set_owner_for_task();
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
RETURNS TRIGGER AS
$body$
BEGIN
NEW.owner := COALESCE(
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
);
IF NEW.owner IS NULL THEN
RAISE EXCEPTION 'Can not insert task without owner';
END IF;
RETURN NEW;
END;
$body$
LANGUAGE plpgsql;
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
create trigger trigger_set_owner_for_task
before insert
on tasks.tasks
for each row
execute procedure tasks.fn_set_owner_for_task();
--19.
--Trigger for validating that tag and task belong to the same project (goal_id)
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
drop function if exists tasks.check_tag_task_same_goal();
create or replace function tasks.check_tag_task_same_goal()
returns trigger as $$
declare
v_tag_goal_id integer;
v_task_goal_id integer;
begin
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
end if;
return new;
end;
$$ language plpgsql;
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
create trigger trigger_check_tag_task_same_goal
before insert on tasks.tasks_to_tags
for each row
execute function tasks.check_tag_task_same_goal();
--20.
-- Remove user from task assignees when removed from project collaboration
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
drop function if exists collaboration.remove_user_from_task_assignees();
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM tasks_auth.task_assignee
WHERE collab_user_id = OLD.user_id
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
CREATE TRIGGER trigger_remove_user_from_task_assignees
BEFORE DELETE
ON collaboration.users_to_goals
FOR EACH ROW
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
+10
View File
@@ -3,6 +3,11 @@ 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 ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
import StartRoutes from '../tv-modules/start/StartRoutes';
@@ -23,6 +28,11 @@ const routes: Record<string, RoutableConstructor> = {
'/module/about': StartRoutes,
'/module/kanban': KanbanRoutes,
'/module/graph': GraphRoutes,
'/module/integrations': IntegrationsRoutes,
'/module/notifications': NotificationsRoutes,
'/module/webhooks': WebhooksRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/sessions': SessionsRoutes,
};
export default routes;
@@ -0,0 +1,53 @@
import type { Request, Response } from 'express';
import { ArkErrors } from 'arktype';
import { getApiTokensManager } from './ApiTokensManager';
import { ApiTokenArkTypeCreate, ApiTokenArkTypeDelete } from './types';
import { Database } from '../../modules/db';
export class ApiTokensController {
private get manager() { return getApiTokensManager(); }
create = async (req: Request, res: Response) => {
const data = ApiTokenArkTypeCreate(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.create(userId, data);
if (!result) return res.status(500).end();
return res.tvJson(result);
};
delete = async (req: Request, res: Response) => {
const data = ApiTokenArkTypeDelete(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.delete(data.id, userId);
return res.tvJson(result);
};
fetch = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.fetchAll(userId);
return res.tvJson(result);
};
fetchPermissions = async (_req: Request, res: Response) => {
const db = Database.getInstance();
const result = await db.query<{ id: number; name: string; description: string; permissionGroup: number }>(
`SELECT id, name, description, permission_group as "permissionGroup" FROM tv_auth.permissions WHERE permission_group <> 1 ORDER BY permission_group, id`
);
return res.tvJson(result?.rows ?? []);
};
}
@@ -0,0 +1,68 @@
import { randomBytes, createHash } from 'crypto';
import { ApiTokensRepository } from './ApiTokensRepository';
import { TOKEN_PREFIX, type ApiTokenArgCreate } from './types';
import type { ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
export type ApiTokenForClient = Omit<ApiTokensSchemaTypeForSelect, 'tokenHash'>;
export class ApiTokensManager {
public readonly repository: ApiTokensRepository;
constructor() {
this.repository = new ApiTokensRepository();
}
async create(userId: number, data: ApiTokenArgCreate): Promise<{ token: string; item: ApiTokenForClient } | null> {
const raw = randomBytes(32).toString('hex');
const fullToken = TOKEN_PREFIX + raw;
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
const record = await this.repository.create({
userId,
name: data.name,
tokenHash,
allowedPermissions: data.allowedPermissions ?? [],
allowedGoalIds: data.allowedGoalIds ?? [],
expiresAt,
});
if (!record) return null;
return { token: fullToken, item: this.toClient(record) };
}
async delete(id: number, userId: number): Promise<boolean> {
return this.repository.delete(id, userId);
}
async fetchAll(userId: number): Promise<ApiTokenForClient[]> {
const tokens = await this.repository.fetchByUserId(userId);
return tokens.map((t) => this.toClient(t));
}
async validateToken(fullToken: string): Promise<ApiTokensSchemaTypeForSelect | null> {
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
const record = await this.repository.findByTokenHash(tokenHash);
if (!record) return null;
if (record.expiresAt && record.expiresAt < new Date()) return null;
this.repository.updateLastUsedAt(record.id).catch(() => {});
return record;
}
private toClient(token: ApiTokensSchemaTypeForSelect): ApiTokenForClient {
const { tokenHash, ...rest } = token;
return rest;
}
}
let _instance: ApiTokensManager | null = null;
export function getApiTokensManager(): ApiTokensManager {
if (!_instance) _instance = new ApiTokensManager();
return _instance;
}
@@ -0,0 +1,50 @@
import { and, eq } from 'drizzle-orm';
import { ApiTokensSchema, type ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
export class ApiTokensRepository {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async create(data: { userId: number; name: string; tokenHash: string; allowedPermissions: string[]; allowedGoalIds: number[]; expiresAt: Date | null }): Promise<ApiTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(ApiTokensSchema).values(data).returning()
);
return result?.[0] ?? null;
}
async delete(id: number, userId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(ApiTokensSchema).where(
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId))
)
);
return !!result?.rowCount;
}
async fetchByUserId(userId: number): Promise<ApiTokensSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.userId, userId))
);
return result ?? [];
}
async findByTokenHash(tokenHash: string): Promise<ApiTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.tokenHash, tokenHash))
);
return result?.[0] ?? null;
}
async updateLastUsedAt(id: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(ApiTokensSchema)
.set({ lastUsedAt: new Date() })
.where(eq(ApiTokensSchema.id, id))
);
}
}
@@ -0,0 +1,27 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { ApiTokensController } from './ApiTokensController';
import { RejectApiTokenAuth } from './middlewares/RejectApiTokenAuth';
export default class ApiTokensRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly controller: ApiTokensController;
constructor() {
this.router = Router();
this.controller = new ApiTokensController();
this.initRoutes();
}
getRouter() {
return this.router;
}
initRoutes() {
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch);
this.router.post('', [IsLoggedIn, RejectApiTokenAuth], this.controller.create);
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete);
this.router.get('/permissions', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetchPermissions);
}
}
@@ -0,0 +1,8 @@
import type { NextFunction, Request, Response } from 'express';
export const RejectApiTokenAuth = (req: Request, res: Response, next: NextFunction) => {
if (req.appUser.isApiTokenAuth()) {
return res.status(403).end();
}
return next();
};
+18
View File
@@ -0,0 +1,18 @@
import { type } from 'arktype';
export const ApiTokenArkTypeCreate = type({
name: 'string',
'allowedPermissions?': 'string[]',
'allowedGoalIds?': 'number[]',
'expiresAt?': 'string|null',
});
export type ApiTokenArgCreate = typeof ApiTokenArkTypeCreate.infer;
export const ApiTokenArkTypeDelete = type({
id: 'number',
});
export type ApiTokenArgDelete = typeof ApiTokenArkTypeDelete.infer;
export const TOKEN_PREFIX = 'tvk_';
+65 -66
View File
@@ -118,7 +118,6 @@ export default class AuthController {
const code = this.generateLoginCode();
$logger.info(data.data, `[AuthController:sendLoginCode] we got data for send login code`);
let userData = await req.appUser.authManager.repository.getUserByLogin(email, isEmail(email));
@@ -129,8 +128,6 @@ export default class AuthController {
}
if (!userData) {
$logger.info(`[AuthController:sendLoginCode] trying to register user ${email}`);
const password = this.makeidLogin(7),
login = this.makeidLogin(7);
@@ -142,17 +139,17 @@ export default class AuthController {
confirmEmailCode: '',
});
if (!id) {
$logger.error(`Can not register user ${email}`);
$logger.error(`Can not register user`);
return res.status(500).end();
}
$logger.info(`[AuthController:sendLoginCode] user registered ${email}`);
$logger.info(`[AuthController:sendLoginCode] user registered`);
}
userData = await req.appUser.authManager.repository.getUserByLogin(email, isEmail(email));
if (!userData) {
$logger.error(`Can not fetch user after registration by code ${email}`);
$logger.error(`Can not fetch user after registration by code`);
return res.status(500).end();
}
@@ -160,11 +157,11 @@ export default class AuthController {
const now = Date.now();
if (!lastUpdate || (lastUpdate && now - +lastUpdate > 60 * 1000)) {
$logger.info(`[AuthController:sendLoginCode] updating login code for user ${email}`);
$logger.info(`[AuthController:sendLoginCode] updating login code for user`);
await req.appUser.authManager.repository.updateLoginCode(code, email);
$logger.info(`[AuthController:sendLoginCode] sending code by email to ${email}`);
$logger.info(`[AuthController:sendLoginCode] sending code by email to`);
await this.sendCodeByEmail(code.split(':')[0], email);
}
@@ -206,8 +203,8 @@ export default class AuthController {
});
if (!id) {
$logger.error(`Can not register user ${user.email} & login ${login}`);
return res.status(500).send(`Can not register user ${user.email} & login ${login}`);
$logger.error(`Can not register user`);
return res.status(500).send(`Can not register user`);
}
userData = await req.appUser.authManager.repository.getUserByLogin(
@@ -217,7 +214,7 @@ export default class AuthController {
}
if (!userData) {
$logger.error(`Can not find user ${user.email} after registration`);
$logger.error(`Can not find user after registration`);
return res.status(500).send(`Can not find user ${user.email} after registration`);
}
@@ -226,7 +223,7 @@ export default class AuthController {
const result = await req.appUser.authManager.repository.updateLoginCode(code, userData.email);
if (!result) {
$logger.error(`Can not update login code for user ${userData.email}`);
$logger.error(`Can not update login code for user`);
return res.status(500).send(`Can not update login code for user`);
}
@@ -252,12 +249,35 @@ export default class AuthController {
return res.redirect(`${process.env.APP_URL}/login?tokens=${encodedAuthData}`);
}
private parseLifetimeToMs(lifetime: string): number {
const match = lifetime.match(/^(\d+)([smhdw])$/)
if (!match) return 1000 * 60 * 60 * 24 * 30
const value = parseInt(match[1])
const unit = match[2]
const multipliers: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
}
return value * (multipliers[unit] || 86_400_000)
}
setRefreshToken = async (res: Response, refreshToken: string) => {
res.cookie(this.refreshTokenCookieName, refreshToken, {
httpOnly: true,
secure: true,
sameSite: "none",
maxAge: 1000 * 60 * 60 * 24 * 30,
maxAge: this.parseLifetimeToMs(this.jwtRefreshExp),
});
}
clearRefreshToken = (res: Response) => {
res.clearCookie(this.refreshTokenCookieName, {
httpOnly: true,
secure: true,
sameSite: "none",
});
}
@@ -296,26 +316,20 @@ export default class AuthController {
return res.status(400).send({ message: 'Code expired, get new code' });
}
const tokenRowId = await req.appUser.authManager.jwtStorage.initTokenRecord(userData.id);
if (!tokenRowId) {
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
req.headers['user-agent']
);
if (!sessionId) {
return res.status(500).end();
}
const tokens = this.getTokens({
id: tokenRowId,
id: sessionId,
userData,
} as const);
const updateResult = await req.appUser.authManager.jwtStorage.updateTokens(
tokens.access,
tokens.refresh,
tokenRowId
);
if (!updateResult) {
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
}
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
await this.setRefreshToken(res, tokens.refresh);
@@ -335,7 +349,6 @@ export default class AuthController {
const userData = await req.appUser.authManager.repository.getUserByLogin(login, isEmail(login));
if (!userData) {
$logger.info(`Can not find user with login ${login}`);
return res.status(400).end();
}
@@ -345,26 +358,20 @@ export default class AuthController {
const valid = await this.comparePasswords(password, userData.password);
if (valid) {
const tokenRowId = await req.appUser.authManager.jwtStorage.initTokenRecord(userData.id);
if (!tokenRowId) {
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
req.headers['user-agent']
);
if (!sessionId) {
return res.status(500).end();
}
const tokens = this.getTokens({
id: tokenRowId,
id: sessionId,
userData,
} as const);
const updateResult = await req.appUser.authManager.jwtStorage.updateTokens(
tokens.access,
tokens.refresh,
tokenRowId
);
if (!updateResult) {
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
}
await this.setRefreshToken(res, tokens.refresh);
return res.json(tokens);
@@ -501,7 +508,7 @@ export default class AuthController {
const result = await req.appUser.authManager.repository.setReminderCodeAndTime(userData.email, code, seconds);
if (!result) {
$logger.error(`Can not set remind_code and time for user ${userData.email}`);
$logger.error(`Can not set remind_code and time for user`);
return res.status(500).send();
}
@@ -551,8 +558,6 @@ export default class AuthController {
const passwordHash = hashSync(parsedData.data.password, 10);
$logger.debug(`Update ${passwordHash} for ${userData.id}`);
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userData.id);
$logger.debug(`Update result ${result}`);
@@ -564,24 +569,19 @@ export default class AuthController {
};
logout = async (req: Request, res: Response) => {
this.setRefreshToken(res, '');
this.clearRefreshToken(res);
const result = req.headers['authorization']?.match(/Bearer\s(\S+)/);
const sessionId = req.appUser.getTokenId();
const userId = req.appUser.getUserData()?.id;
if (!result) {
if (!sessionId || !userId) {
return res.status(401).send({ message: 'Unauthorized' });
}
const tokenId = req.appUser.getTokenId();
if (!tokenId) {
return res.status(401).send({ message: 'Unauthorized' });
}
const deleteResult = await req.appUser.authManager.jwtStorage.deleteTokens(tokenId, result['1']);
const deleteResult = await req.appUser.authManager.sessionStorage.deleteSession(sessionId, userId);
if (!deleteResult) {
return res.status(500).send({ message: 'Failed to revoke token' });
return res.status(500).send({ message: 'Failed to delete session' });
}
return res.status(204).end();
@@ -606,21 +606,20 @@ export default class AuthController {
const payload = await AuthController.validateTokens(refreshToken);
if (!payload) {
$logger.info('Refresh token validation failed');
this.clearRefreshToken(res);
return res.status(400).end();
}
const isActive = await req.appUser.authManager.sessionStorage.isSessionActive(payload.id);
if (!isActive) {
this.clearRefreshToken(res);
return res.status(401).end();
}
const newTokens = this.getTokens(payload);
const update = await req.appUser.authManager.jwtStorage.updateTokens(
newTokens.access,
newTokens.refresh,
payload.id
);
if (!update) {
$logger.error(`Can not refresh tokens for ${payload}`);
return res.status(500).end();
}
await req.appUser.authManager.sessionStorage.updateLastUsed(payload.id);
await this.setRefreshToken(res, newTokens.refresh);
@@ -644,12 +643,12 @@ export default class AuthController {
});
if (!sendResult) {
$logger.error(`Can not send account deletion code for user ${userId}`);
$logger.error(`Can not send account deletion code for user`);
}
const insertCode = await req.appUser.authManager.repository.addDeleteAccountCode(code, userId);
if (!insertCode) {
$logger.error(`Can not insert account deletion code for user ${userId}`);
$logger.error(`Can not insert account deletion code for user`);
return res.status(500).end();
}
return res.status(200).end();
+3 -3
View File
@@ -1,15 +1,15 @@
import type { AppUser } from '../../core/AppUser';
import AuthModel from './AuthModel';
import JwtStorage from './JwtStorage';
import SessionStorage from './SessionStorage';
export class AuthManager {
protected readonly user: AppUser;
public readonly repository: AuthModel;
public readonly jwtStorage: JwtStorage;
public readonly sessionStorage: SessionStorage;
constructor(user: AppUser) {
this.user = user;
this.repository = new AuthModel();
this.jwtStorage = new JwtStorage();
this.sessionStorage = new SessionStorage();
}
}
-76
View File
@@ -1,76 +0,0 @@
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import type { TokensFromDb } from '../../types/auth.types';
export default class JwtStorage {
private db: Database;
constructor() {
this.db = Database.getInstance();
}
async initTokenRecord(userId: number): Promise<number | false> {
try {
const data = await this.db.query<{ id: number }>(
'INSERT INTO tv_auth.user_tokens (user_id) VALUES ($1) RETURNING id;',
[userId]
);
if (data?.rows && data.rows.length > 0) {
return data.rows[0].id;
}
return false;
} catch (error: any) {
$logger.error({
userId,
errorMessage: error.message,
errorStack: error.stack,
}, 'Can not complete initTokenRecord');
return false;
}
}
async updateTokens(accessToken: string, refreshToken: string, rowId: number): Promise<boolean> {
const query = `
UPDATE tv_auth.user_tokens
SET access_token = $1, refresh_token = $2
WHERE id = $3;
`;
try {
const res = await this.db.query(query, [accessToken, refreshToken, rowId]);
return !!(res.rowCount && res.rowCount > 0);
} catch (error: any) {
$logger.error({ errorMessage: error.message, errorStack: error.stack }, 'Error updating tokens:');
return false;
}
}
async fetchTokens(rowId: number): Promise<TokensFromDb | false> {
const query = 'SELECT * FROM tv_auth.user_tokens WHERE id = $1;';
try {
const res = await this.db.query<TokensFromDb>(query, [rowId]);
if (res?.rows && res.rows.length > 0) {
return res.rows[0];
}
return false;
} catch (error: any) {
$logger.error({
rowId,
errorMessage: error.message,
errorStack: error.stack,
}, 'Error fetching tokens');
return false;
}
}
async deleteTokens(userId: number, accessToken: string): Promise<boolean> {
try {
const query = 'DELETE FROM tv_auth.user_tokens WHERE user_id = $1 AND access_token = $2;';
await this.db.query(query, [userId, accessToken]);
return true;
} catch (_error: any) {
return false;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { and, eq, ne } from 'drizzle-orm'
import { UserTokensSchema } from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch, parseDeviceName } from '../../utils/helpers'
export default class SessionStorage {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async createSession(userId: number, ip: string | undefined, userAgent: string | undefined): Promise<number | false> {
const deviceName = parseDeviceName(userAgent)
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(UserTokensSchema).values({
userId,
userIp: ip || null,
deviceName,
userAgent: userAgent || null,
lastUsedAt: new Date(),
}).returning({ id: UserTokensSchema.id })
)
return result?.[0]?.id ?? false
}
async isSessionActive(sessionId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ id: UserTokensSchema.id })
.from(UserTokensSchema)
.where(eq(UserTokensSchema.id, sessionId))
)
return !!(result && result.length > 0)
}
async updateLastUsed(sessionId: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(UserTokensSchema)
.set({ lastUsedAt: new Date() })
.where(eq(UserTokensSchema.id, sessionId))
)
}
async deleteSession(sessionId: number, userId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(UserTokensSchema)
.where(and(eq(UserTokensSchema.id, sessionId), eq(UserTokensSchema.userId, userId)))
)
return !!result?.rowCount
}
async deleteAllSessions(userId: number, excludeSessionId?: number): Promise<boolean> {
if (excludeSessionId) {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(UserTokensSchema)
.where(and(
eq(UserTokensSchema.userId, userId),
ne(UserTokensSchema.id, excludeSessionId)
))
)
return !!result
}
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(UserTokensSchema)
.where(eq(UserTokensSchema.userId, userId))
)
return !!result
}
async fetchUserSessions(userId: number) {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select()
.from(UserTokensSchema)
.where(eq(UserTokensSchema.userId, userId))
.orderBy(UserTokensSchema.lastUsedAt)
)
return result ?? []
}
}
@@ -7,7 +7,7 @@ import { Database } from '../../../modules/db';
import type { UserJwtPayload } from '../../../types/auth.types';
import { delay } from '../../../utils/helpers';
import AuthModel from '../AuthModel';
import JwtStorage from '../JwtStorage';
import JwtStorage from '../SessionStorage';
const port = 1809;
const url = `http://localhost:${port}`;
@@ -69,13 +69,9 @@ describe('Login API', () => {
expect((payloadRefresh as any).userData).toHaveProperty('login');
expect((payloadRefresh as any).userData).toHaveProperty('email');
const jwtStorage = new JwtStorage();
const result = await jwtStorage.fetchTokens(payloadRefresh.id);
if (!result) {
throw new Error('Can not fetch tokens');
}
expect(result.access_token).toBeTruthy();
const sessionStorage = new JwtStorage();
const isActive = await sessionStorage.isSessionActive(payloadRefresh.id);
expect(isActive).toBe(true);
});
it('Registration', async () => {
@@ -2,17 +2,17 @@ import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { Database } from '../../../modules/db';
import type { RegisterUserInDb } from '../../../types/auth.types';
import AuthModel from '../AuthModel';
import JwtStorage from '../JwtStorage';
import SessionStorage from '../SessionStorage';
describe('AuthModel Integration Tests', () => {
let jwtStorage: JwtStorage;
describe('SessionStorage Integration Tests', () => {
let sessionStorage: SessionStorage;
let authModel: AuthModel;
let emailNum: number;
let userId: number;
let rowId: number;
let sessionId: number;
beforeEach(async () => {
jwtStorage = new JwtStorage();
sessionStorage = new SessionStorage();
authModel = new AuthModel();
emailNum = Date.now();
@@ -24,7 +24,7 @@ describe('AuthModel Integration Tests', () => {
block: 0,
};
userId = (await authModel.registerUserInDb(userData)) as number;
rowId = (await jwtStorage.initTokenRecord(userId)) as number;
sessionId = (await sessionStorage.createSession(userId, '127.0.0.1', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120')) as number;
});
afterAll(async () => {
@@ -32,40 +32,34 @@ describe('AuthModel Integration Tests', () => {
await db.query("delete from tv_auth.users where login not in ('user', 'user1', 'user3')");
});
it('initTokenRecord', async () => {
expect(rowId).toBeTruthy();
it('createSession', async () => {
expect(sessionId).toBeTruthy();
const deleteAllSession = await authModel.clearAllSessionTokensForUser(userId);
expect(deleteAllSession).toBe(true);
});
it('updateTokens', async () => {
const updateResult = await jwtStorage.updateTokens('access-1', 'refresh-1', rowId);
expect(updateResult).toBe(true);
it('isSessionActive', async () => {
const isActive = await sessionStorage.isSessionActive(sessionId);
expect(isActive).toBe(true);
const isInactive = await sessionStorage.isSessionActive(999999);
expect(isInactive).toBe(false);
});
it('fetchTokens', async () => {
let fetchResult = await jwtStorage.fetchTokens(rowId);
expect(fetchResult).toBeTruthy();
expect(fetchResult).toHaveProperty('id');
expect(fetchResult).toHaveProperty('user_id');
expect(fetchResult).toHaveProperty('access_token');
expect(fetchResult).toHaveProperty('refresh_token');
expect(fetchResult).toHaveProperty('user_ip');
expect(fetchResult).toHaveProperty('time_creation');
const updateResult = await jwtStorage.updateTokens('access-1', 'refresh-1', rowId);
expect(updateResult).toBe(true);
fetchResult = await jwtStorage.fetchTokens(rowId);
expect(fetchResult).toBeTruthy();
if (fetchResult) {
expect(fetchResult.access_token).toBe('access-1');
expect(fetchResult.refresh_token).toBe('refresh-1');
}
it('fetchUserSessions', async () => {
const sessions = await sessionStorage.fetchUserSessions(userId);
expect(sessions.length).toBeGreaterThan(0);
expect(sessions[0]).toHaveProperty('id');
expect(sessions[0]).toHaveProperty('userId');
expect(sessions[0]).toHaveProperty('deviceName');
expect(sessions[0]).toHaveProperty('userIp');
});
it('deleteTokens', async () => {
const result = await jwtStorage.deleteTokens(userId, 'access-1');
it('deleteSession', async () => {
const result = await sessionStorage.deleteSession(sessionId, userId);
expect(result).toBe(true);
const isActive = await sessionStorage.isSessionActive(sessionId);
expect(isActive).toBe(false);
});
});
@@ -2,10 +2,19 @@ import type { NextFunction, Request, Response } from 'express';
import AuthController from '../AuthController';
export const IsLoggedIn = async (req: Request, res: Response, next: NextFunction) => {
if (req.appUser.isApiTokenAuth() && !req.appUser.isBlocked()) {
return next();
}
const token = req.headers['authorization']?.split(' ')[1];
if (token) {
const userPayload = await AuthController.validateTokens(token);
if (userPayload && req.appUser.getTokenId() === userPayload.id && !req.appUser.isBlocked()) {
if (
userPayload
&& req.appUser.getTokenId() === userPayload.id
&& req.appUser.getHasActiveToken()
&& !req.appUser.isBlocked()
) {
return next();
}
}
@@ -1,5 +1,6 @@
import { type } from 'arktype';
import type { Request, Response } from 'express';
import { eventBus } from '../../core/EventBus';
import { $logger } from '../../modules/logget';
import {
CollaborationArkTypeAddUser,
@@ -83,6 +84,14 @@ export class CollaborationController {
const user = await req.appUser.collaborationManager.addUserNew(output);
if (user) {
eventBus.emit('collaboration.userAdded', {
goalId: output.goalId,
email: output.email.toLowerCase(),
initiatorId: req.appUser.getUserData()!.id,
});
}
return res.tvJson(user ?? null);
};
@@ -93,7 +102,17 @@ export class CollaborationController {
return res.status(400).send(output.summary);
}
return res.tvJson(await req.appUser.collaborationManager.deleteUserNew(output));
const result = await req.appUser.collaborationManager.deleteUserNew(output);
if (result) {
eventBus.emit('collaboration.userRemoved', {
goalId: output.goalId,
collaborationUserId: output.id,
initiatorId: req.appUser.getUserData()!.id,
});
}
return res.tvJson(result);
};
toggleUserRolesNew = async (req: Request, res: Response) => {
@@ -103,7 +122,15 @@ export class CollaborationController {
return res.status(400).send(output.summary);
}
return res.tvJson(await req.appUser.collaborationManager.toggleUserRolesNew(output));
const result = await req.appUser.collaborationManager.toggleUserRolesNew(output);
eventBus.emit('collaboration.rolesChanged', {
goalId: output.goalId,
collaborationUserId: output.userId,
initiatorId: req.appUser.getUserData()!.id,
});
return res.tvJson(result);
};
fetchAllUsersNew = async (req: Request, res: Response) => {
@@ -108,7 +108,7 @@ export class CollaborationManager {
}
async addUser(args: AddUserArg): Promise<CollaborationUserInDb | false> {
const userId = await this.repository.addUserForCollaboration(args.goalId, args.email);
const userId = await this.repository.addUserForCollaboration(args.goalId, args.email.toLowerCase());
if (!userId) {
return false;
}
@@ -121,7 +121,10 @@ export class CollaborationManager {
}
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
const user = await this.repository.addUserForCollaborationNew(args);
const user = await this.repository.addUserForCollaborationNew({
...args,
email: args.email.toLowerCase(),
});
if (!user) return null;
return {
+15 -5
View File
@@ -142,20 +142,30 @@ export default class GoalsManager {
const sharedGoals = await this.goalsRepository.fetchSharedGoalsForUser(this.user!);
const ownGoals = await this.goalsRepository.fetchGoalsNew(this.user.getUserData()?.id!);
const allowedGoalIds = this.user.getAllowedGoalIds();
const filterByAllowed = allowedGoalIds && allowedGoalIds.length > 0;
const filteredOwnGoals = filterByAllowed
? ownGoals.filter((g) => allowedGoalIds.includes(g.id))
: ownGoals;
const filteredSharedGoals = filterByAllowed
? sharedGoals.filter((g) => allowedGoalIds.includes(g.id))
: sharedGoals;
let ownGoalsWithPermissions: GoalsItemForClientWithPermissions[] = [];
let sharedGoalsWithPermissions: GoalsItemForClientWithPermissions[] = [];
if (ownGoals.length > 0) {
if (filteredOwnGoals.length > 0) {
const permChecker = await this.user.permissionsFetcher.getPermissionsForType(
ownGoals[0].id,
filteredOwnGoals[0].id,
GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL
);
ownGoalsWithPermissions = ownGoals.map((g) => ({ ...g, permissions: permChecker.getAllPermissions() }));
ownGoalsWithPermissions = filteredOwnGoals.map((g) => ({ ...g, permissions: permChecker.getAllPermissions() }));
}
if (sharedGoals.length > 0) {
if (filteredSharedGoals.length > 0) {
sharedGoalsWithPermissions = await Promise.all(
sharedGoals.map(async (g) => {
filteredSharedGoals.map(async (g) => {
return {
...g,
permissions: (
@@ -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))
+6 -3
View File
@@ -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}`,
},
},
);
}
+46
View File
@@ -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 }));
};
+3 -3
View File
@@ -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) {
+16 -2
View File
@@ -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,146 @@
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, localHourToUtc } from '../utils';
const DEADLINE_JOB = 'deadline-notification';
const DEFAULT_MORNING_HOUR = 9;
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 tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : null;
const deadlineDay = tz
? localHourToUtc(task.endDate, DEFAULT_MORNING_HOUR, tz)
: 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;
}
}
}
+123
View File
@@ -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;
}
+42
View File
@@ -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;
}
}
@@ -0,0 +1,65 @@
import { eq } from 'drizzle-orm';
import { CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
import { getCentrifugoClient } from '../../core/CentrifugoClient';
import type { Dispatcher } from '../../core/Dispatcher';
import { eventBus, type AppEvents } from '../../core/EventBus';
import { Database } from '../../modules/db';
export class RealtimeDispatcher implements Dispatcher {
register(): void {
eventBus.on('collaboration.userAdded', (data) => this.onCollaborationUserAdded(data));
eventBus.on('collaboration.userRemoved', (data) => this.onCollaborationUserRemoved(data));
eventBus.on('collaboration.rolesChanged', (data) => this.onCollaborationRolesChanged(data));
}
async registerWorkers(): Promise<void> {}
private async onCollaborationUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
const userId = await this.resolveAuthUserIdByEmail(data.email);
if (!userId) return;
await this.publishToUser(userId, 'goals.changed', { goalId: data.goalId });
}
private async onCollaborationUserRemoved(data: AppEvents['collaboration.userRemoved']): Promise<void> {
const userId = await this.resolveAuthUserIdByCollaborationUserId(data.collaborationUserId);
if (!userId) return;
await this.publishToUser(userId, 'goals.changed', { goalId: data.goalId });
}
private async onCollaborationRolesChanged(data: AppEvents['collaboration.rolesChanged']): Promise<void> {
const userId = await this.resolveAuthUserIdByCollaborationUserId(data.collaborationUserId);
if (!userId) return;
await this.publishToUser(userId, 'goals.changed', { goalId: data.goalId });
}
private async publishToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void> {
const centrifugo = getCentrifugoClient();
await centrifugo.publishToUser(userId, event, data);
}
private async resolveAuthUserIdByEmail(email: string): Promise<number | null> {
const db = Database.getInstance();
const result = await db.dbDrizzle
.select({ id: UsersSchema.id })
.from(UsersSchema)
.where(eq(UsersSchema.email, email))
.limit(1);
return result[0]?.id ?? null;
}
private async resolveAuthUserIdByCollaborationUserId(collaborationUserId: number): Promise<number | null> {
const db = Database.getInstance();
const result = await db.dbDrizzle
.select({ id: UsersSchema.id })
.from(CollaborationUsersSchema)
.innerJoin(UsersSchema, eq(CollaborationUsersSchema.email, UsersSchema.email))
.where(eq(CollaborationUsersSchema.id, collaborationUserId))
.limit(1);
return result[0]?.id ?? null;
}
}
@@ -0,0 +1,51 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { SessionDeleteSchema } from './types'
export class SessionsController {
fetch = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const currentSessionId = req.appUser.getTokenId()
const sessions = await req.appUser.authManager.sessionStorage.fetchUserSessions(userId)
const result = sessions.map((s) => ({
id: s.id,
deviceName: s.deviceName,
userIp: s.userIp,
createdAt: s.timeCreation,
lastUsedAt: s.lastUsedAt,
isCurrent: s.id === currentSessionId,
}))
return res.tvJson(result)
}
delete = async (req: Request, res: Response) => {
const data = SessionDeleteSchema(req.body)
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary)
}
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const currentSessionId = req.appUser.getTokenId()
if (data.id === currentSessionId) {
return res.status(400).send('Cannot delete current session')
}
const result = await req.appUser.authManager.sessionStorage.deleteSession(data.id, userId)
return res.tvJson(result)
}
deleteAll = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const currentSessionId = req.appUser.getTokenId()
const result = await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId)
return res.tvJson(result)
}
}
@@ -0,0 +1,26 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { SessionsController } from './SessionsController'
export default class SessionsRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: SessionsController
constructor() {
this.router = Router()
this.controller = new SessionsController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch)
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete)
this.router.delete('/all', [IsLoggedIn, RejectApiTokenAuth], this.controller.deleteAll)
}
}
+7
View File
@@ -0,0 +1,7 @@
import { type } from 'arktype'
export const SessionDeleteSchema = type({
id: 'number',
})
export type SessionDeleteArg = typeof SessionDeleteSchema.infer
+5 -5
View File
@@ -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) {
+58 -13
View File
@@ -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 };
}
+31 -3
View File
@@ -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);
}
}
+66
View File
@@ -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;
}
+3 -8
View File
@@ -34,14 +34,6 @@ export type UserJwtPayload = z.infer<typeof UserJwtPayloadSchema>; //{ id: numbe
export type RegisterUserInDb = z.infer<typeof RegisterUserInDbSchema>;
export type TokensFromDb = {
id: number;
user_id: number;
access_token: string;
refresh_token: string;
user_ip: string;
time_creation: string;
};
export const ConfirmEmailReqDataSchema = z.object({
login: z.string(),
@@ -131,6 +123,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 =
+33
View File
@@ -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');
}
+19
View File
@@ -1,3 +1,4 @@
import { UAParser } from 'ua-parser-js';
import { $logger } from '../modules/logget';
export function isEmail(email: string): boolean {
@@ -42,6 +43,24 @@ export async function callWithCatch<T>(func: () => Promise<T>): Promise<T | null
}
export function parseDeviceName(userAgent: string | undefined): string {
if (!userAgent) return 'Unknown'
const parser = new UAParser(userAgent)
const result = parser.getResult()
const parts: string[] = []
if (result.browser.name) {
parts.push(result.browser.version ? `${result.browser.name} ${result.browser.version.split('.')[0]}` : result.browser.name)
}
if (result.device.model && result.device.model !== 'undefined') {
parts.push(result.device.model)
} else if (result.os.name) {
parts.push(result.os.name)
}
return parts.length > 0 ? parts.join(', ') : 'Unknown'
}
export const chunk = <T>(array: T[], size: number): T[][] => {
if (!Array.isArray(array)) {
throw new TypeError('Expected array');
+44
View File
@@ -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.
::
::
+2
View File
@@ -0,0 +1,2 @@
title: Getting Started
icon: false
+46
View File
@@ -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.
+221
View File
@@ -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

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