mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aff4c77f2 | |||
| 1d6af3ab7d | |||
| 5600d261bc | |||
| 35776d9eb5 | |||
| 9f0cfccdc6 | |||
| ba17eff713 | |||
| 08ee2af865 | |||
| 4412ba1adf | |||
| 2ad29b77f9 | |||
| 9307ab5e45 | |||
| 2f4e84b54b | |||
| c403673f4d | |||
| 5c4859743e | |||
| 0bf0052909 | |||
| 0e1455b947 | |||
| 63e2481b9d | |||
| ade7c5d007 | |||
| fc3d03f932 | |||
| 9de2b64acf | |||
| ef206635f6 |
+12
-1
@@ -46,4 +46,15 @@ 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
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
# Centrifugo (real-time notifications, optional)
|
||||
# CENTRIFUGO_API_URL=http://localhost:8000
|
||||
# CENTRIFUGO_API_KEY=your_centrifugo_api_key_here
|
||||
# CENTRIFUGO_TOKEN_SECRET=your_centrifugo_token_secret_here
|
||||
# Public port that clients use to connect to Centrifugo (exposed port, not internal docker port)
|
||||
# CENTRIFUGO_PUBLIC_PORT=8000
|
||||
@@ -0,0 +1,6 @@
|
||||
CENTRIFUGO_TOKEN_HMAC_SECRET_KEY=taskview-centrifugo-secret-change-me
|
||||
CENTRIFUGO_API_KEY=taskview-centrifugo-api-key-change-me
|
||||
CENTRIFUGO_ALLOWED_ORIGINS=*
|
||||
CENTRIFUGO_ADMIN=true
|
||||
CENTRIFUGO_ADMIN_PASSWORD=admin
|
||||
CENTRIFUGO_ADMIN_SECRET=admin-secret-change-me
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"allow_subscribe_for_client": true,
|
||||
"user_personal_channel_namespace": "personal",
|
||||
"namespaces": [
|
||||
{
|
||||
"name": "personal",
|
||||
"presence": false,
|
||||
"join_leave": false,
|
||||
"history_size": 10,
|
||||
"history_ttl": "300s",
|
||||
"force_recovery": true,
|
||||
"allow_subscribe_for_client": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
centrifugo:
|
||||
image: centrifugo/centrifugo:v5
|
||||
restart: unless-stopped
|
||||
command: centrifugo -c config.json
|
||||
env_file:
|
||||
- ./.env.centrifugo
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./centrifugo/config.json:/centrifugo/config.json
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.24.0",
|
||||
"version": "1.41.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
@@ -28,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",
|
||||
@@ -55,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",
|
||||
@@ -62,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": {
|
||||
|
||||
+4
-1
@@ -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());
|
||||
@@ -81,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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -23,11 +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;
|
||||
@@ -43,6 +48,7 @@ export class AppUser {
|
||||
this.kanbanManager = new KanbanManager(this);
|
||||
this.graphManager = new GraphManager(this);
|
||||
this.integrationsManager = new IntegrationsManager(this);
|
||||
this.notificationsManager = new NotificationsManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
@@ -72,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
interface CentrifugoPublishPayload {
|
||||
channel: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CentrifugoClient {
|
||||
private readonly apiUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.CENTRIFUGO_API_URL;
|
||||
const key = process.env.CENTRIFUGO_API_KEY;
|
||||
this.enabled = !!(url && key);
|
||||
this.apiUrl = url || '';
|
||||
this.apiKey = key || '';
|
||||
|
||||
if (!this.enabled) {
|
||||
$logger.warn('[Centrifugo] Not configured — real-time notifications disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async publish(channel: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
if (!this.enabled) return false;
|
||||
|
||||
try {
|
||||
const payload: CentrifugoPublishPayload = { channel, data };
|
||||
const response = await fetch(`${this.apiUrl}/api/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `apikey ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
$logger.error({ status: response.status }, '[Centrifugo] Publish failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
$logger.error({ err }, '[Centrifugo] Publish error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async publishToUser(userId: number, event: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
return this.publish(`personal:#${userId}`, { event, ...data });
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
static generateConnectionToken(userId: number): string {
|
||||
const secret = process.env.CENTRIFUGO_TOKEN_SECRET || process.env.JWT_SIGN || '';
|
||||
return jwt.sign(
|
||||
{ sub: String(userId) },
|
||||
secret,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: CentrifugoClient | null = null;
|
||||
|
||||
export function getCentrifugoClient(): CentrifugoClient {
|
||||
if (!_instance) {
|
||||
_instance = new CentrifugoClient();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Dispatcher {
|
||||
register(): void;
|
||||
registerWorkers(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { PgBoss } from 'pg-boss';
|
||||
import { $logger } from '../modules/logget';
|
||||
import { Database } from '../modules/db';
|
||||
|
||||
let boss: PgBoss | null = null;
|
||||
|
||||
export async function startJobQueue(): Promise<PgBoss> {
|
||||
boss = new PgBoss({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: +process.env.DB_PORT!,
|
||||
schema: 'pgboss',
|
||||
});
|
||||
|
||||
boss.on('error', (err) => {
|
||||
$logger.error(err, '[JobQueue] Error');
|
||||
});
|
||||
|
||||
await boss.start();
|
||||
$logger.info('[JobQueue] Started');
|
||||
|
||||
return boss;
|
||||
}
|
||||
|
||||
export function getJobQueue(): PgBoss {
|
||||
if (!boss) {
|
||||
throw new Error('JobQueue not started. Call startJobQueue() first.');
|
||||
}
|
||||
return boss;
|
||||
}
|
||||
|
||||
/** Cancel jobs by singletonKey — finds and deletes matching queued jobs */
|
||||
export async function cancelJobBySingletonKey(queueName: string, singletonKey: string): Promise<void> {
|
||||
if (!boss) return;
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ id: string }>(
|
||||
`SELECT id FROM pgboss.job WHERE name = $1 AND singleton_key = $2 AND state IN ('created', 'retry')`,
|
||||
[queueName, singletonKey],
|
||||
);
|
||||
|
||||
const count = result?.rows?.length ?? 0;
|
||||
if (count === 0) {
|
||||
$logger.info(`[JobQueue] Cancel: no jobs found for key="${singletonKey}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of result!.rows) {
|
||||
await boss.deleteJob(queueName, row.id);
|
||||
$logger.info(`[JobQueue] Deleted job id=${row.id} key="${singletonKey}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -328,5 +328,106 @@
|
||||
"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,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();
|
||||
@@ -4,6 +4,10 @@ import CollaborationRolesRoutes from '../tv-modules/collaboration-roles/Collabor
|
||||
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';
|
||||
@@ -25,6 +29,10 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/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();
|
||||
};
|
||||
@@ -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_';
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -4,8 +4,8 @@ import { logError } from '../../utils/api';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { verifyGitHubWebhookSignature } from './providers/github.provider';
|
||||
import { verifyGitLabWebhookToken } from './providers/gitlab.provider';
|
||||
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 {
|
||||
@@ -174,6 +174,7 @@ export default class IntegrationsController {
|
||||
|
||||
if (action === 'opened') {
|
||||
if (!mapping) {
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
@@ -181,6 +182,8 @@ export default class IntegrationsController {
|
||||
issueNumber,
|
||||
'open',
|
||||
issueBody,
|
||||
false,
|
||||
`${GITHUB_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'edited') {
|
||||
@@ -255,6 +258,7 @@ export default class IntegrationsController {
|
||||
|
||||
if (action === 'open') {
|
||||
if (!mapping) {
|
||||
const repoPath = req.body?.project?.path_with_namespace;
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
@@ -262,6 +266,8 @@ export default class IntegrationsController {
|
||||
issueIid,
|
||||
'open',
|
||||
issueDescription,
|
||||
false,
|
||||
`${GITLAB_BASE_URL}/${repoPath}/-/issues/${issueIid}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
|
||||
@@ -10,8 +10,8 @@ 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 } from './providers/github.provider';
|
||||
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken } from './providers/gitlab.provider';
|
||||
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;
|
||||
@@ -197,7 +197,15 @@ export class IntegrationsManager {
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number };
|
||||
// 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') {
|
||||
@@ -212,6 +220,7 @@ export class IntegrationsManager {
|
||||
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({
|
||||
@@ -223,6 +232,7 @@ export class IntegrationsManager {
|
||||
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) {
|
||||
@@ -237,6 +247,7 @@ export class IntegrationsManager {
|
||||
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';
|
||||
@@ -249,6 +260,7 @@ export class IntegrationsManager {
|
||||
note: issue.description ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
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';
|
||||
@@ -117,6 +117,7 @@ export class IntegrationsRepository {
|
||||
issueState: string,
|
||||
note?: string | null,
|
||||
complete?: boolean,
|
||||
sourceUrl?: string | null,
|
||||
): Promise<IntegrationTaskMapSchemaTypeForSelect | false> {
|
||||
const tasksRepo = new TasksRepository();
|
||||
const kanbanOrder = await tasksRepo.getNextKanbanOrder(goalId);
|
||||
@@ -128,6 +129,7 @@ export class IntegrationsRepository {
|
||||
complete: complete ?? false,
|
||||
note: note || null,
|
||||
kanbanOrder,
|
||||
sourceUrl: sourceUrl || null,
|
||||
}).returning();
|
||||
const [mapping] = await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values({
|
||||
integrationId,
|
||||
@@ -241,6 +243,29 @@ export class IntegrationsRepository {
|
||||
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)
|
||||
@@ -251,7 +276,7 @@ export class IntegrationsRepository {
|
||||
}
|
||||
|
||||
async createTasksAndMappingsBatch(
|
||||
items: Array<{ goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number }>,
|
||||
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;
|
||||
@@ -267,6 +292,7 @@ export class IntegrationsRepository {
|
||||
complete: item.complete,
|
||||
note: item.note,
|
||||
kanbanOrder: item.kanbanOrder,
|
||||
sourceUrl: item.sourceUrl,
|
||||
})),
|
||||
).returning({ id: TasksSchema.id });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const GITHUB_BASE_URL = process.env.GITHUB_BASE_URL || 'https://github.com';
|
||||
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`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const GITLAB_BASE_URL = process.env.GITLAB_BASE_URL || 'https://gitlab.com';
|
||||
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`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
KanbanArkTypeFetchTasksForColumn,
|
||||
KanbanArkTypeFilters,
|
||||
KanbanArkTypeGetTasksOrderForColumnAndCursor,
|
||||
KanbanArkTypeUpdateTasksOrder,
|
||||
KanbanSchemaAddStatus,
|
||||
@@ -53,7 +54,12 @@ export class KanbanController {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn(data));
|
||||
const filters = KanbanArkTypeFilters(req.query);
|
||||
if (filters instanceof ArkErrors) {
|
||||
return res.status(400).send(filters.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn({ ...data, filters }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DeleteKanbanStatus,
|
||||
type KanbanAddStatus,
|
||||
type KanbanArgFetchTasksForColumn,
|
||||
type KanbanArgFilters,
|
||||
type KanbanArgGetTasksOrderForColumnAndCursor,
|
||||
type KanbanArgUpdateTasksOrder,
|
||||
type KanbanStatusForClient,
|
||||
@@ -44,7 +45,7 @@ export class KanbanManager {
|
||||
return KanbanStatusToClientSchema.parse(status);
|
||||
}
|
||||
|
||||
async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> {
|
||||
async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> {
|
||||
const [tasks, columnVersion] = await Promise.all([
|
||||
this.user.tasksManager.fetchTasksForKanbanColumn(data),
|
||||
this.repository.getColumnVersion(data.goalId, data.columnId)
|
||||
@@ -176,8 +177,7 @@ export class KanbanManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// первая задача в колонке
|
||||
newOrder = KANBAN_GAP;
|
||||
newOrder = await this.user.tasksManager.repository.getNextKanbanOrder(data.goalId);
|
||||
}
|
||||
|
||||
if (newOrder !== null) {
|
||||
|
||||
@@ -54,14 +54,28 @@ export const KanbanArkTypeStatusToClient = type({
|
||||
|
||||
export type KanbanStatusClient = typeof KanbanArkTypeStatusToClient.infer;
|
||||
|
||||
const NullableNumberFromString = type('string|number|null').pipe((v) => (v === 'null' || v === null || v === undefined) ? null : Number(v));
|
||||
|
||||
export const KanbanArkTypeFetchTasksForColumn = type({
|
||||
goalId: NumberFromString,
|
||||
columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
cursor: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
columnId: NullableNumberFromString,
|
||||
cursor: NullableNumberFromString,
|
||||
});
|
||||
|
||||
export type KanbanArgFetchTasksForColumn = typeof KanbanArkTypeFetchTasksForColumn.infer;
|
||||
|
||||
const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) => {
|
||||
if (!v) return [];
|
||||
return v.split(',').map(Number).filter((n) => !isNaN(n));
|
||||
});
|
||||
|
||||
export const KanbanArkTypeFilters = type({
|
||||
'listIds?': NumberArrayFromCommaSeparatedString,
|
||||
'assigneeIds?': NumberArrayFromCommaSeparatedString,
|
||||
});
|
||||
|
||||
export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer;
|
||||
|
||||
export const KanbanArkTypeGetTasksOrderForColumnAndCursor = type({
|
||||
goalId: NumberFromString,
|
||||
columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { eq, and, or, isNull, inArray } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { TasksSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { getNotificationService } from './NotificationService';
|
||||
import { NotificationMessages } from './NotificationMessages';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
|
||||
import { DeadlineScheduler } from './schedulers/DeadlineScheduler';
|
||||
import { NotificationType } from './types';
|
||||
import { parseUtcTime } from './utils';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
|
||||
const CLEANUP_JOB = 'notifications-cleanup';
|
||||
const NOTIFICATIONS_RETENTION_DAYS = 1;
|
||||
|
||||
export class NotificationDispatcher implements Dispatcher {
|
||||
private readonly deadlineScheduler = new DeadlineScheduler();
|
||||
private readonly notificationsRepo = new NotificationsRepository();
|
||||
private readonly deviceTokensRepo = new DeviceTokensRepository();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.onTaskCreated(data));
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
|
||||
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
await this.cleanupWorker();
|
||||
await this.deadlineScheduler.registerWorker();
|
||||
}
|
||||
|
||||
private async cleanupWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(CLEANUP_JOB);
|
||||
await boss.schedule(CLEANUP_JOB, '0 3 * * *');
|
||||
await boss.work(CLEANUP_JOB, async () => {
|
||||
await this.notificationsRepo.deleteOlderThanDays(NOTIFICATIONS_RETENTION_DAYS);
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
}
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
if (data.changes.complete === true) {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasDeadlineChange =
|
||||
data.changes.endDate !== undefined ||
|
||||
data.changes.endTime !== undefined;
|
||||
|
||||
if (!hasDeadlineChange) return;
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Rescheduling deadline for task=${data.task.id}`);
|
||||
|
||||
this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE);
|
||||
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
} else {
|
||||
await this.deadlineScheduler.cancel(data.task.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
if (data.userIds.length === 0) return;
|
||||
|
||||
const db = Database.getInstance();
|
||||
const task = await db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.id, data.taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete))));
|
||||
|
||||
if (!task[0]) return;
|
||||
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const authUserRows = await db.dbDrizzle
|
||||
.select({ userId: authUsers.id })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(inArray(CollaborationUsersSchema.id, data.userIds));
|
||||
|
||||
const recipientIds = authUserRows
|
||||
.map((r) => r.userId)
|
||||
.filter((id) => id !== data.initiatorId);
|
||||
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
await this.handleAssignNotification(data, task[0], recipientIds);
|
||||
await this.handleExpiredDeadlineNotification(data, task[0], recipientIds);
|
||||
}
|
||||
|
||||
private async handleAssignNotification(
|
||||
data: AppEvents['task.assigneesChanged'],
|
||||
task: typeof TasksSchema.$inferSelect,
|
||||
recipientIds: number[],
|
||||
): Promise<void> {
|
||||
const initiatorName = await this.resolveUserName(data.initiatorId);
|
||||
const message = NotificationMessages.assign(task.description, initiatorName);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Assign notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.ASSIGN,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleExpiredDeadlineNotification(
|
||||
data: AppEvents['task.assigneesChanged'],
|
||||
task: typeof TasksSchema.$inferSelect,
|
||||
recipientIds: number[],
|
||||
): Promise<void> {
|
||||
if (!task.endDate) return;
|
||||
|
||||
const isExpired = task.endTime
|
||||
? (parseUtcTime(task.endDate, task.endTime) ?? new Date()) <= new Date()
|
||||
: new Date(`${task.endDate}T00:00:00Z`) <= new Date();
|
||||
|
||||
if (!isExpired) return;
|
||||
|
||||
const tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : 'UTC';
|
||||
const message = NotificationMessages.deadline(task.description, task.endDate, task.endTime, tz);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Expired deadline notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.taskId, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.taskId);
|
||||
}
|
||||
|
||||
private async resolveUserName(userId: number): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
.select({ login: UsersSchema.login })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, userId))
|
||||
.limit(1);
|
||||
return result[0]?.login || 'Someone';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NotificationMessage } from './types';
|
||||
import { parseUtcTime } from './utils';
|
||||
|
||||
export class NotificationMessages {
|
||||
static deadline(description: string | null, endDate: string, endTime: string | null, timezone: string): NotificationMessage {
|
||||
const title = `Task: ${description || 'Task'}`;
|
||||
|
||||
if (endTime) {
|
||||
const deadline = parseUtcTime(endDate, endTime);
|
||||
if (deadline) {
|
||||
const formatted = deadline.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return { title, body: `Deadline: ${formatted}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { title, body: `Deadline: ${endDate}` };
|
||||
}
|
||||
|
||||
static assign(taskDescription: string | null, assignedByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `Assigned to you by ${assignedByName}`,
|
||||
};
|
||||
}
|
||||
|
||||
static mention(taskDescription: string | null, mentionedByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `${mentionedByName} mentioned you`,
|
||||
};
|
||||
}
|
||||
|
||||
static comment(taskDescription: string | null, commentByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `New comment by ${commentByName}`,
|
||||
};
|
||||
}
|
||||
|
||||
static statusChange(taskDescription: string | null, newStatus: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `Status changed to ${newStatus}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { NotificationChannel } from './types';
|
||||
|
||||
export interface NotificationMeta {
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationProvider {
|
||||
readonly channel: NotificationChannel;
|
||||
send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { NotificationMeta, NotificationProvider } from './NotificationProvider';
|
||||
import type { NotificationMessage } from './types';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
import { UserPreferencesRepository } from './repositories/UserPreferencesRepository';
|
||||
import { NotificationChannel, type NotificationType } from './types';
|
||||
import { CentrifugoProvider } from './providers/CentrifugoProvider';
|
||||
import { FCMProvider } from './providers/FCMProvider';
|
||||
|
||||
export class NotificationService {
|
||||
private readonly providers: Map<NotificationChannel, NotificationProvider>;
|
||||
private readonly repo: NotificationsRepository;
|
||||
private readonly preferences: UserPreferencesRepository;
|
||||
|
||||
constructor() {
|
||||
this.repo = new NotificationsRepository();
|
||||
this.preferences = new UserPreferencesRepository();
|
||||
|
||||
const providerList: NotificationProvider[] = [
|
||||
new CentrifugoProvider(),
|
||||
new FCMProvider(),
|
||||
];
|
||||
|
||||
this.providers = new Map();
|
||||
for (const p of providerList) {
|
||||
this.providers.set(p.channel, p);
|
||||
}
|
||||
}
|
||||
|
||||
async notify(
|
||||
userId: number,
|
||||
type: NotificationType,
|
||||
message: NotificationMessage,
|
||||
meta: NotificationMeta,
|
||||
taskId: number | null = null,
|
||||
): Promise<void> {
|
||||
$logger.info(`[NotificationService] notify user=${userId}, type=${type}, title="${message.title}"`);
|
||||
|
||||
const channels = await this.preferences.getEnabledChannels(userId, type, meta.goalId);
|
||||
if (channels.length === 0) {
|
||||
$logger.info(`[NotificationService] All channels disabled for user=${userId}, type=${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await this.repo.create({ userId, taskId, type, title: message.title, body: message.body });
|
||||
if (!notification) {
|
||||
$logger.warn(`[NotificationService] Failed to create notification record for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.info(`[NotificationService] Created notification id=${notification.id}, sending to channels=[${channels.join(',')}]`);
|
||||
|
||||
await Promise.allSettled(
|
||||
channels
|
||||
.map((channel) => this.providers.get(channel))
|
||||
.filter(Boolean)
|
||||
.map((provider) =>
|
||||
provider!.send(userId, notification, meta).catch((err) => {
|
||||
$logger.error(err, `[NotificationService] Provider "${provider!.channel}" failed for user ${userId}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async notifyMany(
|
||||
userIds: number[],
|
||||
type: NotificationType,
|
||||
message: NotificationMessage,
|
||||
meta: NotificationMeta,
|
||||
taskId: number | null = null,
|
||||
): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
userIds.map((userId) => this.notify(userId, type, message, meta, taskId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!_instance) {
|
||||
_instance = new NotificationService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CentrifugoClient } from '../../core/CentrifugoClient';
|
||||
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
|
||||
import { UserPreferencesRepository } from './repositories/UserPreferencesRepository';
|
||||
import { UserPreferences } from './UserPreferences';
|
||||
|
||||
const NotificationArkTypeMarkRead = type({
|
||||
notificationId: 'number',
|
||||
});
|
||||
|
||||
const DeviceTokenArkType = type({
|
||||
token: 'string',
|
||||
platform: "'android' | 'ios'",
|
||||
timezone: 'string',
|
||||
});
|
||||
|
||||
export class NotificationsController {
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : undefined;
|
||||
return res.tvJson(await req.appUser.notificationsManager.fetchByUser(cursor));
|
||||
};
|
||||
|
||||
markRead = async (req: Request, res: Response) => {
|
||||
const out = NotificationArkTypeMarkRead(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
return res.tvJson(await req.appUser.notificationsManager.markRead(out.notificationId));
|
||||
};
|
||||
|
||||
markAllRead = async (_req: Request, res: Response) => {
|
||||
return res.tvJson(await _req.appUser.notificationsManager.markAllRead());
|
||||
};
|
||||
|
||||
registerDevice = async (req: Request, res: Response) => {
|
||||
console.log('[registerDevice] body:', JSON.stringify(req.body));
|
||||
const out = DeviceTokenArkType(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
console.log('[registerDevice] validation error:', out.summary);
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).send('Unauthorized');
|
||||
|
||||
const repo = new DeviceTokensRepository();
|
||||
return res.tvJson(await repo.register(userId, out.token, out.platform, out.timezone));
|
||||
};
|
||||
|
||||
unregisterDevice = async (req: Request, res: Response) => {
|
||||
const { token } = req.body;
|
||||
if (!token || typeof token !== 'string') {
|
||||
return res.status(400).send('token is required');
|
||||
}
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).send('Unauthorized');
|
||||
|
||||
const repo = new DeviceTokensRepository();
|
||||
return res.tvJson(await repo.unregister(userId, token));
|
||||
};
|
||||
|
||||
getPreferences = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(400).send('Bad request');
|
||||
|
||||
const repo = new UserPreferencesRepository();
|
||||
const prefs = await repo.load(userId);
|
||||
return res.tvJson({ settings: prefs.toJSON() });
|
||||
};
|
||||
|
||||
savePreferences = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(400).send('Bad request');
|
||||
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return res.status(400).send('settings is required');
|
||||
}
|
||||
|
||||
const repo = new UserPreferencesRepository();
|
||||
const prefs = new UserPreferences(settings);
|
||||
const result = await repo.save(userId, prefs);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
connectionToken = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const publicUrl = process.env.CENTRIFUGO_PUBLIC_URL;
|
||||
if (!publicUrl) {
|
||||
return res.tvJson({ token: null, url: null });
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
|
||||
const token = CentrifugoClient.generateConnectionToken(userId);
|
||||
return res.tvJson({ token, url });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
|
||||
export class NotificationsManager {
|
||||
private readonly user: AppUser;
|
||||
public readonly repository: NotificationsRepository;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new NotificationsRepository();
|
||||
}
|
||||
|
||||
async fetchByUser(cursor?: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return { notifications: [] };
|
||||
const notifications = await this.repository.fetchByUser(userId, cursor);
|
||||
return { notifications };
|
||||
}
|
||||
|
||||
async markRead(notificationId: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
async markAllRead() {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markAllRead(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { NotificationsController } from './NotificationsController';
|
||||
|
||||
export default class NotificationsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: NotificationsController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new NotificationsController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.patch('/read', [IsLoggedIn], this.controller.markRead);
|
||||
this.router.patch('/read-all', [IsLoggedIn], this.controller.markAllRead);
|
||||
this.router.get('/preferences', [IsLoggedIn], this.controller.getPreferences);
|
||||
this.router.put('/preferences', [IsLoggedIn], this.controller.savePreferences);
|
||||
this.router.get('/connection-token', [IsLoggedIn], this.controller.connectionToken);
|
||||
this.router.post('/device/register', [IsLoggedIn], this.controller.registerDevice);
|
||||
this.router.post('/device/unregister', [IsLoggedIn], this.controller.unregisterDevice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
NotificationChannel,
|
||||
NotificationType,
|
||||
type TypeSettings,
|
||||
type TypeSettingsMap,
|
||||
type DeadlineTypeSettings,
|
||||
type DeadlineIntervals,
|
||||
type SettingsJson,
|
||||
} from './types';
|
||||
|
||||
const ALL_CHANNELS: NotificationChannel[] = [
|
||||
NotificationChannel.PUSH,
|
||||
NotificationChannel.WEBSOCKET
|
||||
];
|
||||
|
||||
/**
|
||||
* Manages user notification preferences.
|
||||
* Opt-out model: undefined = enabled.
|
||||
*
|
||||
* Priority:
|
||||
* 1. projects[goalId][type] — highest
|
||||
* 2. global[type] — fallback
|
||||
* 3. undefined — enabled
|
||||
*/
|
||||
export class UserPreferences {
|
||||
private data: SettingsJson;
|
||||
|
||||
constructor(json: unknown) {
|
||||
this.data = (json && typeof json === 'object' ? json : {}) as SettingsJson;
|
||||
}
|
||||
|
||||
getEnabledChannels(type: NotificationType, goalId?: number): NotificationChannel[] {
|
||||
const resolved = this.resolveTypeSettings(type, goalId);
|
||||
if (!resolved?.channels) return ALL_CHANNELS;
|
||||
return ALL_CHANNELS.filter((ch) => resolved.channels![ch] !== false);
|
||||
}
|
||||
|
||||
isChannelEnabled(type: NotificationType, channel: NotificationChannel, goalId?: number): boolean {
|
||||
const resolved = this.resolveTypeSettings(type, goalId);
|
||||
return resolved?.channels?.[channel] !== false;
|
||||
}
|
||||
|
||||
getDeadlineIntervals(goalId?: number): DeadlineIntervals | undefined {
|
||||
const resolved = this.resolveTypeSettings(NotificationType.DEADLINE, goalId) as DeadlineTypeSettings | undefined;
|
||||
return resolved?.intervals;
|
||||
}
|
||||
|
||||
getEnabledDeadlineIntervals(goalId?: number): number[] {
|
||||
const intervals = this.getDeadlineIntervals(goalId);
|
||||
if (!intervals) return [0]; // default: notify at deadline
|
||||
return Object.entries(intervals)
|
||||
.filter(([, enabled]) => enabled !== false)
|
||||
.map(([minutes]) => Number(minutes));
|
||||
}
|
||||
|
||||
getGlobalTypeSettings<T extends NotificationType>(type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.global?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
getProjectTypeSettings<T extends NotificationType>(goalId: number, type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.projects?.[String(goalId)]?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
setGlobalChannel(type: NotificationType, channel: NotificationChannel, enabled: boolean): void {
|
||||
if (!this.data.global) this.data.global = {};
|
||||
if (!this.data.global[type]) this.data.global[type] = {} as TypeSettingsMap[typeof type];
|
||||
if (!this.data.global[type]!.channels) this.data.global[type]!.channels = {};
|
||||
this.data.global[type]!.channels![channel] = enabled;
|
||||
}
|
||||
|
||||
setDeadlineIntervals(intervals: DeadlineIntervals, goalId?: number): void {
|
||||
if (goalId !== undefined) {
|
||||
const key = String(goalId);
|
||||
if (!this.data.projects) this.data.projects = {};
|
||||
if (!this.data.projects[key]) this.data.projects[key] = {};
|
||||
if (!this.data.projects[key][NotificationType.DEADLINE]) this.data.projects[key][NotificationType.DEADLINE] = {};
|
||||
(this.data.projects[key][NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals;
|
||||
} else {
|
||||
if (!this.data.global) this.data.global = {};
|
||||
if (!this.data.global[NotificationType.DEADLINE]) this.data.global[NotificationType.DEADLINE] = {};
|
||||
(this.data.global[NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals;
|
||||
}
|
||||
}
|
||||
|
||||
setProjectChannel(goalId: number, type: NotificationType, channel: NotificationChannel, enabled: boolean): void {
|
||||
const key = String(goalId);
|
||||
if (!this.data.projects) this.data.projects = {};
|
||||
if (!this.data.projects[key]) this.data.projects[key] = {};
|
||||
if (!this.data.projects[key][type]) this.data.projects[key][type] = {} as TypeSettingsMap[typeof type];
|
||||
if (!this.data.projects[key][type]!.channels) this.data.projects[key][type]!.channels = {};
|
||||
this.data.projects[key][type]!.channels![channel] = enabled;
|
||||
}
|
||||
|
||||
removeProjectSettings(goalId: number): void {
|
||||
delete this.data.projects?.[String(goalId)];
|
||||
}
|
||||
|
||||
toJSON(): SettingsJson {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
private resolveTypeSettings(type: NotificationType, goalId?: number): TypeSettings | undefined {
|
||||
const globalSettings = this.data.global?.[type];
|
||||
|
||||
if (goalId === undefined) return globalSettings;
|
||||
|
||||
const projectSettings = this.data.projects?.[String(goalId)]?.[type];
|
||||
if (!projectSettings) return globalSettings;
|
||||
if (!globalSettings) return projectSettings;
|
||||
|
||||
return {
|
||||
channels: { ...globalSettings.channels, ...projectSettings.channels },
|
||||
...('intervals' in globalSettings || 'intervals' in projectSettings
|
||||
? {
|
||||
intervals: {
|
||||
...(globalSettings as DeadlineTypeSettings).intervals,
|
||||
...(projectSettings as DeadlineTypeSettings).intervals,
|
||||
}
|
||||
}
|
||||
: {}
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { getCentrifugoClient } from '../../../core/CentrifugoClient';
|
||||
import type { NotificationMeta, NotificationProvider } from '../NotificationProvider';
|
||||
import { NotificationChannel } from '../types';
|
||||
|
||||
export class CentrifugoProvider implements NotificationProvider {
|
||||
readonly channel = NotificationChannel.WEBSOCKET;
|
||||
|
||||
async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void> {
|
||||
await getCentrifugoClient().publishToUser(userId, 'notification', {
|
||||
notification,
|
||||
goalId: meta.goalId,
|
||||
goalListId: meta.goalListId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import admin from 'firebase-admin';
|
||||
import { getMessaging } from 'firebase-admin/messaging';
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { NotificationMeta, NotificationProvider } from '../NotificationProvider';
|
||||
import { NotificationChannel } from '../types';
|
||||
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
export class FCMProvider implements NotificationProvider {
|
||||
readonly channel = NotificationChannel.PUSH;
|
||||
|
||||
private static initialized = false;
|
||||
private static messaging: admin.messaging.Messaging | null = null;
|
||||
|
||||
private readonly repo = new DeviceTokensRepository();
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.enabled = FCMProvider.init();
|
||||
}
|
||||
|
||||
private static init(): boolean {
|
||||
if (FCMProvider.initialized) return true;
|
||||
|
||||
const credentialsPath = process.env.FIREBASE_CREDENTIALS_PATH;
|
||||
if (!credentialsPath) {
|
||||
$logger.warn('[FCM] FIREBASE_CREDENTIALS_PATH not configured — push notifications disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(credentialsPath),
|
||||
});
|
||||
FCMProvider.messaging = getMessaging();
|
||||
FCMProvider.messaging.enableLegacyHttpTransport();
|
||||
FCMProvider.initialized = true;
|
||||
$logger.info('[FCM] Firebase initialized with legacy HTTP/1.1 transport');
|
||||
return true;
|
||||
} catch (err) {
|
||||
$logger.error(err, '[FCM] Failed to initialize Firebase');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void> {
|
||||
if (!this.enabled || !FCMProvider.messaging) return;
|
||||
|
||||
const tokens = await this.repo.getByUserId(userId);
|
||||
$logger.info(`[FCM] User ${userId}: found ${tokens.length} device token(s)`);
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
const message: admin.messaging.MulticastMessage = {
|
||||
tokens: tokens.map((t) => t.token),
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body || undefined,
|
||||
},
|
||||
data: {
|
||||
type: notification.type,
|
||||
taskId: notification.taskId ? String(notification.taskId) : '',
|
||||
goalId: String(meta.goalId),
|
||||
goalListId: meta.goalListId ? String(meta.goalListId) : '',
|
||||
notificationId: String(notification.id),
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
$logger.info(`[FCM] Sending to ${tokens.length} token(s) for user ${userId}, title="${notification.title}"`);
|
||||
const response = await FCMProvider.messaging.sendEachForMulticast(message);
|
||||
$logger.info(`[FCM] Result: success=${response.successCount}, failure=${response.failureCount}`);
|
||||
|
||||
if (response.failureCount > 0) {
|
||||
const invalidTokens: string[] = [];
|
||||
response.responses.forEach((resp, idx) => {
|
||||
if (!resp.success) {
|
||||
const code = resp.error?.code;
|
||||
if (code === 'messaging/invalid-registration-token' || code === 'messaging/registration-token-not-registered') {
|
||||
invalidTokens.push(tokens[idx].token);
|
||||
} else {
|
||||
$logger.error(resp.error, '[FCM] Failed to send to token');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of invalidTokens) {
|
||||
await this.repo.deleteByToken(token);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
$logger.error(err, `[FCM] Failed to send multicast for user ${userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { DeviceTokensSchema, type DeviceTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
export class DeviceTokensRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async register(userId: number, token: string, platform: string, timezone: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(DeviceTokensSchema).values({
|
||||
userId,
|
||||
token,
|
||||
platform,
|
||||
timezone,
|
||||
}).onConflictDoUpdate({
|
||||
target: [DeviceTokensSchema.userId, DeviceTokensSchema.token],
|
||||
set: { timezone },
|
||||
})
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async unregister(userId: number, token: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(and(
|
||||
eq(DeviceTokensSchema.userId, userId),
|
||||
eq(DeviceTokensSchema.token, token),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getByUserId(userId: number): Promise<DeviceTokensSchemaTypeForSelect[]> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select()
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
) || [];
|
||||
}
|
||||
|
||||
async getTimezoneByUserId(userId: number): Promise<string> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ timezone: DeviceTokensSchema.timezone })
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0]?.timezone || 'UTC';
|
||||
}
|
||||
|
||||
async deleteByToken(token: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.token, token))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq, desc, lt, sql } from 'drizzle-orm';
|
||||
import { NotificationsSchema, TasksSchema, type NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
export class NotificationsRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { userId: number; taskId: number | null; type: string; title: string; body: string | null }): Promise<NotificationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(NotificationsSchema).values({
|
||||
userId: data.userId,
|
||||
taskId: data.taskId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchByUser(userId: number, cursor?: number) {
|
||||
const limit = 30;
|
||||
const conditions = [eq(NotificationsSchema.userId, userId)];
|
||||
if (cursor) {
|
||||
conditions.push(lt(NotificationsSchema.id, cursor));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({
|
||||
id: NotificationsSchema.id,
|
||||
userId: NotificationsSchema.userId,
|
||||
taskId: NotificationsSchema.taskId,
|
||||
type: NotificationsSchema.type,
|
||||
title: NotificationsSchema.title,
|
||||
body: NotificationsSchema.body,
|
||||
read: NotificationsSchema.read,
|
||||
createdAt: NotificationsSchema.createdAt,
|
||||
goalId: TasksSchema.goalId,
|
||||
goalListId: TasksSchema.goalListId,
|
||||
})
|
||||
.from(NotificationsSchema)
|
||||
.leftJoin(TasksSchema, eq(NotificationsSchema.taskId, TasksSchema.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(NotificationsSchema.id))
|
||||
.limit(limit)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async markRead(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.id, notificationId),
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async deleteByTaskAndType(taskId: number, type: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(NotificationsSchema)
|
||||
.where(and(
|
||||
eq(NotificationsSchema.taskId, taskId),
|
||||
eq(NotificationsSchema.type, type),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async deleteOlderThanDays(days: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(NotificationsSchema)
|
||||
.where(lt(NotificationsSchema.createdAt, sql`NOW() - INTERVAL '${sql.raw(String(days))} days'`))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async markAllRead(userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
eq(NotificationsSchema.read, false),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NotificationPreferencesSchema } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
import { UserPreferences } from '../UserPreferences';
|
||||
import type { NotificationChannel, NotificationType } from '../types';
|
||||
|
||||
export class UserPreferencesRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async load(userId: number): Promise<UserPreferences> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ settings: NotificationPreferencesSchema.settings })
|
||||
.from(NotificationPreferencesSchema)
|
||||
.where(eq(NotificationPreferencesSchema.userId, userId))
|
||||
.limit(1)
|
||||
);
|
||||
return new UserPreferences(result?.[0]?.settings);
|
||||
}
|
||||
|
||||
async save(userId: number, preferences: UserPreferences): Promise<boolean> {
|
||||
const settings = preferences.toJSON();
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(NotificationPreferencesSchema)
|
||||
.values({ userId, settings })
|
||||
.onConflictDoUpdate({
|
||||
target: [NotificationPreferencesSchema.userId],
|
||||
set: { settings },
|
||||
})
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getEnabledChannels(userId: number, type: NotificationType, goalId?: number): Promise<NotificationChannel[]> {
|
||||
const prefs = await this.load(userId);
|
||||
return prefs.getEnabledChannels(type, goalId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export enum NotificationType {
|
||||
DEADLINE = 'deadline',
|
||||
ASSIGN = 'assign',
|
||||
MENTION = 'mention',
|
||||
COMMENT = 'comment',
|
||||
STATUS_CHANGE = 'status_change',
|
||||
}
|
||||
|
||||
export enum NotificationChannel {
|
||||
PUSH = 'push',
|
||||
WEBSOCKET = 'websocket',
|
||||
EMAIL = 'email',
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base settings — only channels (for instant notification types)
|
||||
*/
|
||||
export interface BaseTypeSettings {
|
||||
channels?: Partial<Record<NotificationChannel, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed intervals in minutes before deadline.
|
||||
* Key = minutes, value = enabled. undefined = enabled (opt-out)
|
||||
*/
|
||||
export interface DeadlineIntervals {
|
||||
0?: boolean; // at deadline
|
||||
15?: boolean; // 15 min before
|
||||
30?: boolean; // 30 min before
|
||||
60?: boolean; // 1 hour before
|
||||
1440?: boolean; // 1 day before
|
||||
}
|
||||
|
||||
/**
|
||||
* Deadline has intervals (minutes before deadline to notify)
|
||||
*/
|
||||
export interface DeadlineTypeSettings extends BaseTypeSettings {
|
||||
intervals?: DeadlineIntervals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant types — only channels, no intervals
|
||||
*/
|
||||
export type InstantTypeSettings = BaseTypeSettings;
|
||||
|
||||
/**
|
||||
* Maps each notification type to its allowed settings shape
|
||||
*/
|
||||
export interface TypeSettingsMap {
|
||||
[NotificationType.DEADLINE]: DeadlineTypeSettings;
|
||||
[NotificationType.ASSIGN]: InstantTypeSettings;
|
||||
[NotificationType.MENTION]: InstantTypeSettings;
|
||||
[NotificationType.COMMENT]: InstantTypeSettings;
|
||||
[NotificationType.STATUS_CHANGE]: InstantTypeSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all possible type settings (for generic use)
|
||||
*/
|
||||
export type TypeSettings = DeadlineTypeSettings | InstantTypeSettings;
|
||||
|
||||
/**
|
||||
* @example
|
||||
* {
|
||||
* "global": {
|
||||
* "deadline": {
|
||||
* "channels": { "push": true, "websocket": true, "email": false },
|
||||
* "intervals": { "0": true, "15": true, "60": true, "1440": false }
|
||||
* },
|
||||
* "assign": {
|
||||
* "channels": { "push": true, "websocket": true }
|
||||
* },
|
||||
* "mention": {
|
||||
* "channels": { "push": false }
|
||||
* }
|
||||
* },
|
||||
* "projects": {
|
||||
* "42": {
|
||||
* "deadline": {
|
||||
* "channels": { "push": false },
|
||||
* "intervals": { "0": true, "30": true }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Result for user with these settings:
|
||||
* - deadline globally: push + websocket, remind at 0/15/60 min before (1440 disabled)
|
||||
* - deadline in project 42: websocket only (push overridden), remind at 0/30 min before
|
||||
* - assign globally: push + websocket
|
||||
* - mention globally: websocket only (push explicitly disabled)
|
||||
* - comment: no settings → all channels enabled (opt-out)
|
||||
*/
|
||||
export interface SettingsJson {
|
||||
global?: { [K in NotificationType]?: TypeSettingsMap[K] };
|
||||
projects?: Record<string, { [K in NotificationType]?: TypeSettingsMap[K] }>;
|
||||
}
|
||||
|
||||
export interface DeadlineJobData {
|
||||
taskId: number;
|
||||
description: string;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
endDate: string;
|
||||
endTime: string | null;
|
||||
initiatorId: number | null;
|
||||
immediate: boolean;
|
||||
}
|
||||
|
||||
export interface TaskWithDeadline {
|
||||
id: number;
|
||||
description: string | null;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
owner: number | null;
|
||||
endDate: string | null;
|
||||
endTime: string | null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Parse UTC time string (HH:mm:ss) with date into a Date object
|
||||
*/
|
||||
export function parseUtcTime(dateStr: string, timeStr: string): Date | null {
|
||||
const match = timeStr.match(/^(\d{2}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
return new Date(`${dateStr}T${match[1]}:${match[2]}:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a local hour (e.g. 9 for 09:00) in a given IANA timezone
|
||||
* to a UTC Date for the specified date string (YYYY-MM-DD)
|
||||
*/
|
||||
export function localHourToUtc(dateStr: string, hour: number, timezone: string): Date {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const utcMidnight = new Date(`${dateStr}T00:00:00Z`);
|
||||
const parts = formatter.formatToParts(utcMidnight);
|
||||
const tzHour = Number(parts.find(p => p.type === 'hour')?.value ?? 0);
|
||||
const tzDay = Number(parts.find(p => p.type === 'day')?.value ?? 0);
|
||||
const utcDay = utcMidnight.getUTCDate();
|
||||
|
||||
let offsetHours = tzHour - utcMidnight.getUTCHours();
|
||||
if (tzDay > utcDay) offsetHours += 24;
|
||||
else if (tzDay < utcDay) offsetHours -= 24;
|
||||
|
||||
const result = new Date(`${dateStr}T00:00:00Z`);
|
||||
result.setUTCHours(hour - offsetHours, 0, 0, 0);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = new Date(`${dateStr}T00:00:00Z`);
|
||||
fallback.setUTCHours(hour, 0, 0, 0);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type } from 'arktype'
|
||||
|
||||
export const SessionDeleteSchema = type({
|
||||
id: 'number',
|
||||
})
|
||||
|
||||
export type SessionDeleteArg = typeof SessionDeleteSchema.infer
|
||||
@@ -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 */
|
||||
@@ -350,7 +350,6 @@ export class TasksController {
|
||||
};
|
||||
|
||||
fetchTasksNew = async (req: Request, res: Response) => {
|
||||
// debugger;
|
||||
const out = TaskArkTypeFetchTasksNew(req.query);
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import type {
|
||||
AddTaskArg,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
type TaskForClientNew,
|
||||
type TasksArgToggleTaskUsers,
|
||||
} from './tasks.server.types';
|
||||
import type { KanbanArgFetchTasksForColumn } from '../kanban/types';
|
||||
import type { KanbanArgFetchTasksForColumn, KanbanArgFilters } from '../kanban/types';
|
||||
|
||||
type TaskFieldPermissionKey = keyof typeof TaskFieldPermissionsForEditOrCreation & keyof TasksSchemaTypeForSelect;
|
||||
|
||||
@@ -80,7 +81,7 @@ export class TasksManager {
|
||||
|
||||
const tagsMap: Record<number, number[]> = {};
|
||||
const ids = tasks.map((t) => t.id);
|
||||
|
||||
|
||||
const tags = await this.repository.fetchTagsForTasks(ids);
|
||||
|
||||
if (tags) {
|
||||
@@ -115,7 +116,7 @@ export class TasksManager {
|
||||
|
||||
const tagsMap: Record<number, number[]> = {};
|
||||
const ids = tasks.map((t) => t.id);
|
||||
|
||||
|
||||
const tags = await this.repository.fetchTagsForTasks(ids);
|
||||
|
||||
if (tags) {
|
||||
@@ -157,7 +158,7 @@ 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(() => {});
|
||||
this.user.integrationsManager.onTaskCompleteChanged(arg.taskId, arg.complete).catch(() => { });
|
||||
|
||||
return new TaskItemForClient(task);
|
||||
}
|
||||
@@ -263,6 +264,13 @@ export class TasksManager {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -274,6 +282,13 @@ export class TasksManager {
|
||||
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]);
|
||||
const result = tasks[0] ?? null;
|
||||
if (!result) return null;
|
||||
@@ -388,19 +403,39 @@ export class TasksManager {
|
||||
}
|
||||
|
||||
const task = await this.repository.addTaskNew(newData);
|
||||
return await this.extendTasksWithTagsAndAssignees(task, true);
|
||||
const result = await this.extendTasksWithTagsAndAssignees(task, true);
|
||||
|
||||
if (task[0]) {
|
||||
eventBus.emit('task.created', {
|
||||
task: task[0],
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteTaskNew(data: TaskArgDelete) {
|
||||
return await this.repository.deleteTaskNew(data);
|
||||
const task = await this.repository.fetchTaskByIdNew(data.taskId);
|
||||
const result = await this.repository.deleteTaskNew(data);
|
||||
if (result) {
|
||||
eventBus.emit('task.deleted', { taskId: data.taskId, goalId: task?.goalId ?? 0, initiatorId: this.user.getUserData()?.id as number });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async toggleTaskUsers(data: TasksArgToggleTaskUsers) {
|
||||
return await this.repository.toggleTaskUsers(data);
|
||||
const result = await this.repository.toggleTaskUsers(data);
|
||||
eventBus.emit('task.assigneesChanged', {
|
||||
taskId: data.taskId,
|
||||
userIds: data.userIds,
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> {
|
||||
const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor);
|
||||
async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> {
|
||||
const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor, data.filters);
|
||||
if (!tasks || tasks.length === 0) return { tasks: [], nextCursor: null };
|
||||
return { tasks: await this.extendTasksWithTagsAndAssignees(tasks), nextCursor: tasks[tasks.length - 1].kanbanOrder };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
TaskArgUpdate,
|
||||
TasksArgToggleTaskUsers,
|
||||
} from './tasks.server.types';
|
||||
import type { KanbanArgFilters } from '../kanban/types';
|
||||
|
||||
export class TasksRepository {
|
||||
private readonly db: Database;
|
||||
@@ -643,7 +644,7 @@ export class TasksRepository {
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null): Promise<TasksSchemaTypeForSelect[]> {
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null, filters?: KanbanArgFilters): Promise<TasksSchemaTypeForSelect[]> {
|
||||
const conditions = [
|
||||
eq(TasksSchema.goalId, goalId),
|
||||
columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId),
|
||||
@@ -653,6 +654,24 @@ export class TasksRepository {
|
||||
if (cursor !== null) {
|
||||
conditions.push(gt(TasksSchema.kanbanOrder, cursor));
|
||||
}
|
||||
if (filters?.listIds && filters.listIds.length > 0) {
|
||||
conditions.push(inArray(TasksSchema.goalListId, filters.listIds));
|
||||
}
|
||||
if (filters?.assigneeIds && filters.assigneeIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select({ one: sql`1` })
|
||||
.from(TasksAssigneeSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(TasksAssigneeSchema.taskId, TasksSchema.id),
|
||||
inArray(TasksAssigneeSchema.collabUserId, filters.assigneeIds)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(and(...conditions)).orderBy(asc(TasksSchema.kanbanOrder)).limit(20)
|
||||
@@ -660,11 +679,13 @@ 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)));
|
||||
@@ -673,8 +694,8 @@ export class TasksRepository {
|
||||
|
||||
static readonly KANBAN_ORDER_GAP = 16384;
|
||||
|
||||
async getNextKanbanOrder(goalId: number, statusId: number | null = null): Promise<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, statusId);
|
||||
async getNextKanbanOrder(goalId: number, columnId?: number | null): Promise<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, columnId);
|
||||
return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import { WebhooksManager } from './WebhooksManager';
|
||||
import {
|
||||
WebhookArkTypeCreate,
|
||||
WebhookArkTypeUpdate,
|
||||
WebhookArkTypeDelete,
|
||||
WebhookArkTypeFetch,
|
||||
WebhookArkTypeById,
|
||||
WebhookArkTypeFetchDeliveries,
|
||||
} from './types';
|
||||
|
||||
export class WebhooksController {
|
||||
private readonly manager = new WebhooksManager();
|
||||
|
||||
create = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeCreate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.create(data);
|
||||
if (!result) return res.status(500).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
update = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeUpdate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.update(data);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeDelete(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.delete(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeFetch(req.query);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.fetchByGoalId(data.goalId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
rotateSecret = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.rotateSecret(data.id);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
testDelivery = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.testDelivery(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchDeliveries = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeFetchDeliveries({ ...req.params, ...req.query });
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.fetchDeliveries(data.id, {
|
||||
cursor: data.cursor,
|
||||
status: data.status,
|
||||
});
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
retryDelivery = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.retryDelivery(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { TasksSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { Database } from '../../modules/db';
|
||||
import { WebhooksRepository } from './WebhooksRepository';
|
||||
import { WebhooksManager } from './WebhooksManager';
|
||||
import type { WebhookDeliverJobData } from './types';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
|
||||
const WEBHOOK_DELIVER_JOB = 'webhook-deliver';
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
export class WebhooksDispatcher implements Dispatcher {
|
||||
private readonly repository = new WebhooksRepository();
|
||||
private readonly manager = new WebhooksManager();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.dispatch('task.created', data.task.goalId, data));
|
||||
eventBus.on('task.updated', (data) => this.dispatch('task.updated', data.task.goalId, data));
|
||||
eventBus.on('task.deleted', (data) => this.dispatch('task.deleted', data.goalId, data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.dispatchAssigneesChanged(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(WEBHOOK_DELIVER_JOB);
|
||||
await boss.work<WebhookDeliverJobData>(WEBHOOK_DELIVER_JOB, async ([job]) => {
|
||||
await this.deliverJob(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatch(event: string, goalId: number, payload: object): Promise<void> {
|
||||
const webhooks = await this.repository.fetchActiveByGoalIdAndEvent(goalId, event);
|
||||
for (const webhook of webhooks) {
|
||||
await this.enqueueDelivery(webhook.id, webhook.url, webhook.secretEncrypted, event, {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
const db = Database.getInstance();
|
||||
const task = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, data.taskId)).limit(1);
|
||||
if (!task[0]) return;
|
||||
await this.dispatch('task.assigneesChanged', task[0].goalId, data);
|
||||
}
|
||||
|
||||
private async enqueueDelivery(webhookId: number, url: string, secretEncrypted: string, event: string, payload: object): Promise<void> {
|
||||
const delivery = await this.repository.createDelivery({ webhookId, event, payload });
|
||||
if (!delivery) return;
|
||||
|
||||
const boss = getJobQueue();
|
||||
await boss.send(WEBHOOK_DELIVER_JOB, {
|
||||
deliveryId: delivery.id,
|
||||
webhookId,
|
||||
url,
|
||||
secretEncrypted,
|
||||
payload,
|
||||
attempt: 1,
|
||||
} satisfies WebhookDeliverJobData);
|
||||
}
|
||||
|
||||
private async deliverJob(data: WebhookDeliverJobData): Promise<void> {
|
||||
const secret = decrypt(data.secretEncrypted);
|
||||
const result = await this.manager.deliver(data.url, secret, data.payload);
|
||||
|
||||
await this.repository.updateDelivery(data.deliveryId, {
|
||||
status: result.success ? 'success' : (data.attempt >= MAX_ATTEMPTS ? 'failed' : 'pending'),
|
||||
responseCode: result.responseCode,
|
||||
attempts: data.attempt,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.repository.resetConsecutiveFailures(data.webhookId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.attempt < MAX_ATTEMPTS) {
|
||||
const boss = getJobQueue();
|
||||
const delay = Math.pow(2, data.attempt) * 5;
|
||||
await boss.send(WEBHOOK_DELIVER_JOB, {
|
||||
...data,
|
||||
attempt: data.attempt + 1,
|
||||
}, { startAfter: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
const failures = await this.repository.incrementConsecutiveFailures(data.webhookId);
|
||||
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
await this.repository.deactivate(data.webhookId);
|
||||
$logger.warn(`[Webhooks] Deactivated webhook=${data.webhookId} after ${failures} consecutive failures`);
|
||||
} else {
|
||||
$logger.warn(`[Webhooks] Delivery failed for webhook=${data.webhookId}, consecutive failures: ${failures}/${MAX_CONSECUTIVE_FAILURES}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { randomBytes, createHmac } from 'crypto';
|
||||
import { encrypt, decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { WebhooksRepository } from './WebhooksRepository';
|
||||
import type { WebhookArgCreate, WebhookArgUpdate } from './types';
|
||||
import type { WebhooksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type WebhookForClient = Omit<WebhooksSchemaTypeForSelect, 'secretEncrypted'>;
|
||||
|
||||
export class WebhooksManager {
|
||||
public readonly repository: WebhooksRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new WebhooksRepository();
|
||||
}
|
||||
|
||||
async create(data: WebhookArgCreate): Promise<{ webhook: WebhookForClient; secret: string } | null> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const secretEncrypted = encrypt(secret);
|
||||
|
||||
const webhook = await this.repository.create({
|
||||
goalId: data.goalId,
|
||||
url: data.url,
|
||||
secretEncrypted,
|
||||
events: data.events,
|
||||
});
|
||||
|
||||
if (!webhook) return null;
|
||||
|
||||
return { webhook: this.toClient(webhook), secret };
|
||||
}
|
||||
|
||||
async update(data: WebhookArgUpdate): Promise<WebhookForClient | null> {
|
||||
const updateData: Partial<{ url: string; events: string[]; isActive: boolean }> = {};
|
||||
if (data.url !== undefined) updateData.url = data.url;
|
||||
if (data.events !== undefined) updateData.events = data.events;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
const webhook = await this.repository.update(data.id, updateData);
|
||||
if (!webhook) return null;
|
||||
return this.toClient(webhook);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhookForClient[]> {
|
||||
const webhooks = await this.repository.fetchByGoalId(goalId);
|
||||
return webhooks.map(w => this.toClient(w));
|
||||
}
|
||||
|
||||
async rotateSecret(id: number): Promise<{ secret: string } | null> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const secretEncrypted = encrypt(secret);
|
||||
const success = await this.repository.updateSecret(id, secretEncrypted);
|
||||
if (!success) return null;
|
||||
return { secret };
|
||||
}
|
||||
|
||||
async testDelivery(id: number): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const webhook = await this.repository.fetchById(id);
|
||||
if (!webhook) return { success: false };
|
||||
|
||||
const secret = decrypt(webhook.secretEncrypted);
|
||||
const payload = {
|
||||
event: 'webhook.test',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { message: 'This is a test webhook delivery' },
|
||||
};
|
||||
|
||||
return this.deliver(webhook.url, secret, payload);
|
||||
}
|
||||
|
||||
async deliver(url: string, secret: string, payload: object): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', secret).update(body).digest('hex');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
return { success: response.ok, responseCode: response.status };
|
||||
} catch (err) {
|
||||
$logger.error(err, `[Webhooks] Delivery failed to ${url}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async retryDelivery(deliveryId: number): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const deliveries = await this.repository.fetchDeliveryById(deliveryId);
|
||||
if (!deliveries) return { success: false };
|
||||
|
||||
const webhook = await this.repository.fetchById(deliveries.webhookId);
|
||||
if (!webhook) return { success: false };
|
||||
|
||||
const secret = decrypt(webhook.secretEncrypted);
|
||||
const result = await this.deliver(webhook.url, secret, deliveries.payload as object);
|
||||
|
||||
await this.repository.updateDelivery(deliveryId, {
|
||||
status: result.success ? 'success' : 'failed',
|
||||
responseCode: result.responseCode,
|
||||
attempts: deliveries.attempts + 1,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string }) {
|
||||
return this.repository.fetchDeliveries(webhookId, options);
|
||||
}
|
||||
|
||||
private toClient(webhook: WebhooksSchemaTypeForSelect): WebhookForClient {
|
||||
const { secretEncrypted, ...rest } = webhook;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { and, desc, eq, lt, sql } from 'drizzle-orm';
|
||||
import { WebhooksSchema, WebhookDeliveriesSchema, type WebhooksSchemaTypeForSelect, type WebhookDeliveriesSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
|
||||
export class WebhooksRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { goalId: number; url: string; secretEncrypted: string; events: string[] }): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(WebhooksSchema).values(data).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<{ url: string; events: string[]; isActive: boolean }>): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, id))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateSecret(id: number, secretEncrypted: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ secretEncrypted, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, id))
|
||||
.returning()
|
||||
);
|
||||
return (result?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.goalId, goalId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchActiveByGoalIdAndEvent(goalId: number, event: string): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(
|
||||
and(
|
||||
eq(WebhooksSchema.goalId, goalId),
|
||||
eq(WebhooksSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return (result ?? []).filter(w => w.events.includes(event));
|
||||
}
|
||||
|
||||
async createDelivery(data: { webhookId: number; event: string; payload: unknown }): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(WebhookDeliveriesSchema).values({
|
||||
webhookId: data.webhookId,
|
||||
event: data.event,
|
||||
payload: data.payload,
|
||||
}).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateDelivery(id: number, data: { status: string; responseCode?: number; attempts: number }): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhookDeliveriesSchema)
|
||||
.set({ ...data, lastAttemptAt: new Date() })
|
||||
.where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDeliveryById(id: number): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhookDeliveriesSchema).where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string; limit?: number }): Promise<WebhookDeliveriesSchemaTypeForSelect[]> {
|
||||
const limit = options?.limit ?? 20;
|
||||
const conditions = [eq(WebhookDeliveriesSchema.webhookId, webhookId)];
|
||||
|
||||
if (options?.cursor) {
|
||||
conditions.push(lt(WebhookDeliveriesSchema.id, options.cursor));
|
||||
}
|
||||
if (options?.status) {
|
||||
conditions.push(eq(WebhookDeliveriesSchema.status, options.status));
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhookDeliveriesSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(WebhookDeliveriesSchema.id))
|
||||
.limit(limit)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async resetConsecutiveFailures(webhookId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: 0 })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
|
||||
async incrementConsecutiveFailures(webhookId: number): Promise<number> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: sql`${WebhooksSchema.consecutiveFailures} + 1` })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
.returning({ consecutiveFailures: WebhooksSchema.consecutiveFailures })
|
||||
);
|
||||
return result?.[0]?.consecutiveFailures ?? 0;
|
||||
}
|
||||
|
||||
async deactivate(webhookId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { WebhooksController } from './WebhooksController';
|
||||
|
||||
export default class WebhooksRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: WebhooksController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new WebhooksController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.post('', [IsLoggedIn], this.controller.create);
|
||||
this.router.patch('', [IsLoggedIn], this.controller.update);
|
||||
this.router.delete('', [IsLoggedIn], this.controller.delete);
|
||||
this.router.post('/rotate-secret', [IsLoggedIn], this.controller.rotateSecret);
|
||||
this.router.post('/test', [IsLoggedIn], this.controller.testDelivery);
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn], this.controller.fetchDeliveries);
|
||||
this.router.post('/retry', [IsLoggedIn], this.controller.retryDelivery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
const NumberFromString = type('string|number').pipe((v) => Number(v));
|
||||
|
||||
export const WebhookArkTypeCreate = type({
|
||||
goalId: 'number',
|
||||
url: 'string',
|
||||
events: 'string[]',
|
||||
});
|
||||
|
||||
export type WebhookArgCreate = typeof WebhookArkTypeCreate.infer;
|
||||
|
||||
export const WebhookArkTypeUpdate = type({
|
||||
id: 'number',
|
||||
'url?': 'string',
|
||||
'events?': 'string[]',
|
||||
'isActive?': 'boolean',
|
||||
});
|
||||
|
||||
export type WebhookArgUpdate = typeof WebhookArkTypeUpdate.infer;
|
||||
|
||||
export const WebhookArkTypeDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
|
||||
export type WebhookArgDelete = typeof WebhookArkTypeDelete.infer;
|
||||
|
||||
export const WebhookArkTypeFetch = type({
|
||||
goalId: NumberFromString,
|
||||
});
|
||||
|
||||
export type WebhookArgFetch = typeof WebhookArkTypeFetch.infer;
|
||||
|
||||
export const WebhookArkTypeById = type({
|
||||
id: NumberFromString,
|
||||
});
|
||||
|
||||
export type WebhookArgById = typeof WebhookArkTypeById.infer;
|
||||
|
||||
const OptionalNumberFromString = type('string|number|undefined').pipe((v) => v === undefined ? undefined : Number(v));
|
||||
|
||||
export const WebhookArkTypeFetchDeliveries = type({
|
||||
id: NumberFromString,
|
||||
'cursor?': OptionalNumberFromString,
|
||||
'status?': 'string',
|
||||
});
|
||||
|
||||
export type WebhookArgFetchDeliveries = typeof WebhookArkTypeFetchDeliveries.infer;
|
||||
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'task.created',
|
||||
'task.updated',
|
||||
'task.deleted',
|
||||
'task.assigneesChanged',
|
||||
] as const;
|
||||
|
||||
export type WebhookEvent = typeof WEBHOOK_EVENTS[number];
|
||||
|
||||
export interface WebhookDeliverJobData {
|
||||
deliveryId: number;
|
||||
webhookId: number;
|
||||
url: string;
|
||||
secretEncrypted: string;
|
||||
payload: object;
|
||||
attempt: number;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: TaskView Documentation
|
||||
description: Official documentation for TaskView - a source-available, self-hosted project and task management platform. Installation guides, feature docs, configuration reference, and more.
|
||||
navigation: false
|
||||
---
|
||||
|
||||
Welcome to the TaskView documentation. TaskView is a self-hosted task management platform for teams and individuals who want full control over their data and workflows.
|
||||
|
||||
## Getting started
|
||||
|
||||
::card-group
|
||||
::card{title="What is TaskView" icon="i-lucide-info" to="/docs/getting-started"}
|
||||
Learn what TaskView is, who it's for, and what features it offers.
|
||||
::
|
||||
::card{title="Installation" icon="i-lucide-download" to="/docs/getting-started/installation"}
|
||||
Deploy TaskView with Docker Compose in 5 minutes.
|
||||
::
|
||||
::card{title="Quick Start" icon="i-lucide-rocket" to="/docs/getting-started/usage"}
|
||||
Create your first project, add lists, and start managing tasks.
|
||||
::
|
||||
::
|
||||
|
||||
## Explore
|
||||
|
||||
::card-group
|
||||
::card{title="Features" icon="i-lucide-layout-grid" to="/docs/features/projects-and-lists"}
|
||||
Projects, tasks, Kanban boards, dependency graphs, and dashboard.
|
||||
::
|
||||
::card{title="Integrations" icon="i-lucide-git-pull-request" to="/docs/integrations/setup"}
|
||||
Connect GitHub and GitLab repositories to sync issues as tasks.
|
||||
::
|
||||
::card{title="Configuration" icon="i-lucide-settings" to="/docs/configuration/environment-variables"}
|
||||
Environment variables, authentication, and server setup.
|
||||
::
|
||||
::card{title="Collaboration" icon="i-lucide-users" to="/docs/collaboration/members"}
|
||||
Team members, roles, and 28 granular permissions.
|
||||
::
|
||||
::card{title="FAQ" icon="i-lucide-circle-help" to="/docs/faq"}
|
||||
Common questions about installation, features, and security.
|
||||
::
|
||||
::card{title="Guides" icon="i-lucide-book-open" to="/docs/guides/deploy-vps-nginx"}
|
||||
Step-by-step guides for production deployment and use cases.
|
||||
::
|
||||
::
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Getting Started
|
||||
icon: false
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: What is TaskView
|
||||
description: TaskView is an open-source, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
|
||||
navigation:
|
||||
icon: i-lucide-house
|
||||
---
|
||||
|
||||
TaskView is a self-hosted task management platform for teams and individuals who want full control over their data and workflows.
|
||||
|
||||
You deploy it on your own server (or run it locally), and everything - tasks, projects, files, user data - stays on your infrastructure. There are no third-party clouds involved, no subscriptions, and no vendor lock-in.
|
||||
|
||||
## Who is it for
|
||||
|
||||
- **Teams with security requirements** - companies that can't send project data to external SaaS platforms
|
||||
- **Self-hosters** - people who prefer running their own tools, like Gitea instead of GitHub or Mattermost instead of Slack
|
||||
- **Small teams and startups** - anyone who wants a capable project manager without paying per seat
|
||||
|
||||
## What you get
|
||||
|
||||
- **Projects and lists** - organize work into projects, each with its own lists, tags, statuses, and team members
|
||||
- **Tasks and subtasks** - create tasks with priorities, deadlines, notes
|
||||
- **Kanban boards** - drag-and-drop tasks with custom statuses per project
|
||||
- **Dependency graphs** - link tasks and visualize dependencies on an interactive graph
|
||||
- **Team collaboration** - invite members, assign roles with granular permissions, control who sees what
|
||||
- **GitHub and GitLab sync** - connect repositories and import issues as tasks, kept in sync via webhooks
|
||||
- **Financial tracking** - attach income and expense amounts to tasks for basic budget tracking
|
||||
- **Task history** - full audit trail with the ability to restore deleted or changed tasks (only props in tasks, not other entities)
|
||||
- **Mobile apps** - Android and iOS apps that sync with your server
|
||||
- **Dashboard** - widgets for today's tasks, upcoming deadlines, recent activity, and completed work
|
||||
|
||||
## Tech stack
|
||||
|
||||
TaskView is a monorepo with three main parts:
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| API server | Node.js, Express, Drizzle ORM, SQL, TypeScript |
|
||||
| Web app | Vue 3, Nuxt UI, TailwindCSS, Pinia, TypeScript |
|
||||
| Database | PostgreSQL 17 |
|
||||
| Mobile | Capacitor 8 (iOS & Android) |
|
||||
|
||||
Everything runs in Docker containers, so deployment is straightforward regardless of your server setup.
|
||||
|
||||
## What's next
|
||||
|
||||
Head to the [Installation](/docs/getting-started/installation) page to get TaskView running on your machine in a few minutes.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Install and deploy TaskView using Docker Compose. Step-by-step setup guide for a self-hosted task management server with PostgreSQL, Node.js API, and Vue web app. Deploy on any server in 5 minutes.
|
||||
navigation:
|
||||
icon: i-lucide-download
|
||||
---
|
||||
|
||||
TaskView runs as a set of Docker containers - a database, an API server, a web app, and a one-time migration runner. The whole setup takes about 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A server or local machine with [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed
|
||||
- Ports `8888` (web) and `1725` (API) available - you can change these in the compose file
|
||||
|
||||
## Step 1: Create a project directory
|
||||
|
||||
```bash
|
||||
mkdir taskview && cd taskview
|
||||
```
|
||||
|
||||
## Step 2: Create environment files
|
||||
|
||||
You need two env files - one for PostgreSQL, one for the TaskView API.
|
||||
|
||||
**`.env.postgresql`** - database credentials:
|
||||
|
||||
```env
|
||||
POSTGRES_USER=taskview_db_user
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=taskviewdb
|
||||
```
|
||||
|
||||
**`.env.taskview`** - application config (**example, do not forget add your data**):
|
||||
|
||||
```env
|
||||
DB_HOST="db"
|
||||
DB_USER="taskview_db_user"
|
||||
DB_PASSWORD="your_secure_password"
|
||||
DB_NAME="taskviewdb"
|
||||
DB_PORT=5432
|
||||
APP_PORT=1401
|
||||
JWT_ALG="HS256"
|
||||
JWT_SIGN="secret"
|
||||
ACCESS_LIFE_TIME="3d"
|
||||
REFRESH_LIFE_TIME="9d"
|
||||
|
||||
SMTP_HOST=smtp
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_ENCRYPTION=tls
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
# Your domain
|
||||
APP_URL="https://app.taskview.tech"
|
||||
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
#You domain
|
||||
GOOGLE_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/google/callback"
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
GITHUB_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/github/callback"
|
||||
APPLE_CLIENT_ID=""
|
||||
APPLE_TEAM_ID=""
|
||||
APPLE_KEY_ID=""
|
||||
APPLE_KEY_LOCATION="/usr/src/app/AuthKey.p8"
|
||||
# Your domain
|
||||
APPLE_CALLBACK_URL="https://api.taskview.tech/module/auth/provider/apple/callback"
|
||||
|
||||
#integrations
|
||||
GITHUB_INTEGRATION_CLIENT_ID=
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback
|
||||
|
||||
GITLAB_INTEGRATION_CLIENT_ID=
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback
|
||||
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
#!!! ADD YOUR DOMAIN SEPARATED BY ","
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://localhost:8888,http://127.0.0.1:3000,http://127.0.0.1:8888"
|
||||
```
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
Replace `your_secure_password` and `JWT_SIGN` with real secrets. Never use the example values in production.
|
||||
::
|
||||
|
||||
## Step 3: Create docker-compose.yml
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
backend:
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env.postgresql
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U taskview_db_user -d taskviewdb"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks: [backend]
|
||||
migration:
|
||||
image: gimanhead/taskview-ce-db-migration:latest
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./.env.taskview
|
||||
networks: [backend]
|
||||
|
||||
taskview-api-server:
|
||||
image: gimanhead/taskview-ce-api-server:latest
|
||||
restart: "unless-stopped"
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=1
|
||||
- net.ipv6.conf.default.disable_ipv6=1
|
||||
ports:
|
||||
- "1725:1401"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- ./.env.taskview
|
||||
volumes:
|
||||
- ./logs:/usr/src/app/logs
|
||||
#- /local/AuthKey.p8:/usr/src/app/AuthKey.p8
|
||||
networks: [backend]
|
||||
|
||||
taskview-webapp:
|
||||
image: gimanhead/taskview-ce-webapp:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:80"
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
```
|
||||
|
||||
## Step 4: Start everything
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Docker will pull the images, start the database, run migrations, and launch the API and web app.
|
||||
|
||||
## Step 5: Open TaskView
|
||||
|
||||
Go to [http://localhost:8888](http://localhost:8888) in your browser. You'll see the login screen.
|
||||
|
||||
The database migration creates a default user so you can log in right away:
|
||||
|
||||
- **Login:** `user`
|
||||
- **Password:** `user1!#Q`
|
||||
|
||||
Use these credentials to verify that everything is working - check that the UI loads, you can create a project, add tasks, etc.
|
||||
|
||||
::callout{icon="i-lucide-alert-triangle" color="error"}
|
||||
**Important:** The default user is for initial setup only. Once you've confirmed the system works, delete the default user and create your own account with a secure password.
|
||||
::
|
||||
|
||||
### Replacing the default user
|
||||
|
||||
1. Log in with the default credentials
|
||||
2. Register a new account with your real email and a strong password
|
||||
3. Delete the default `admin` account
|
||||
|
||||
If you prefer to create the first user directly in the database, generate a password hash:
|
||||
|
||||
```ts
|
||||
import { hashSync } from 'bcryptjs'
|
||||
|
||||
const passwordHash = hashSync('your-secure-password', 12)
|
||||
console.log(passwordHash)
|
||||
```
|
||||
|
||||
Or as a one-liner:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('bcryptjs').hashSync('your-secure-password', 12))"
|
||||
```
|
||||
|
||||
Then insert the user into the database with the generated hash.
|
||||
|
||||
## Updating
|
||||
|
||||
To update TaskView to a new version:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The migration container will automatically apply any new database changes on startup.
|
||||
|
||||
## Production tips
|
||||
|
||||
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
|
||||
- **Update `APP_URL` and `API_URL`** in `.env.taskview` to match your production domain
|
||||
- **Back up the database** - the `pgdata` volume contains all your data
|
||||
- **Set `restart: unless-stopped`** on all services so they survive server reboots
|
||||
- **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details.
|
||||
|
||||
## What's next
|
||||
|
||||
- [Create your first project](/docs/features/projects-and-lists) - set up a project with lists and tasks
|
||||
- [Invite your team](/docs/collaboration/members) - add members and assign roles
|
||||
- [Connect GitHub or GitLab](/docs/integrations/setup) - sync issues as tasks
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Get started with TaskView in 5 minutes - create projects, add task lists, organize work with Kanban boards and dependency graphs. Quick start guide for self-hosted project and task management.
|
||||
navigation:
|
||||
icon: i-lucide-rocket
|
||||
---
|
||||
|
||||
You've installed TaskView and created an account. Here's how to get productive in 5 minutes.
|
||||
|
||||
## Create a project
|
||||
|
||||

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

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

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

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

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

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

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

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

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

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

|
||||

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

|
||||
|
||||
### Today's tasks
|
||||
Tasks due today across all projects. This is your daily focus list - what needs attention right now.
|
||||
|
||||
### Upcoming deadlines
|
||||
Tasks due in the coming days. Helps you plan ahead and avoid last-minute surprises.
|
||||
|
||||
### Recent activity
|
||||
A feed of recent changes - new tasks, completed tasks, updates. Useful for staying in the loop on what your team is doing.
|
||||
|
||||
### Completed tasks
|
||||
What's been finished recently. A satisfying way to see progress and confirm that work is actually getting done.
|
||||
|
||||
## How it works
|
||||
|
||||
The dashboard aggregates data across all projects you have access to. If you're a member of five projects, you'll see tasks from all five.
|
||||
|
||||
Tasks appear on the dashboard based on their deadlines and activity timestamps. There's no separate configuration - the dashboard just reflects the state of your tasks.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Check the dashboard first thing** - it gives you a clear picture of what to focus on today
|
||||
- **Use deadlines consistently** - the dashboard is only as useful as the data behind it. If your tasks don't have deadlines, the "Today" and "Upcoming" widgets won't be helpful.
|
||||
- **Don't ignore overdue tasks** - if something is overdue, either do it, move the deadline, or remove it. A growing list of overdue items makes the dashboard noisy.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Notifications
|
||||
description: Real-time and push notifications in TaskView - deadline alerts, assignment notifications, per-user preferences, and multi-channel delivery via WebSocket and Firebase Cloud Messaging.
|
||||
navigation:
|
||||
icon: i-lucide-bell
|
||||
---
|
||||
|
||||
TaskView notifies you when things happen in your projects. Notifications are delivered through multiple channels and can be customized per user.
|
||||
|
||||
## Notification types
|
||||
|
||||
| Type | When it fires | Delivery | Status |
|
||||
|---|---|---|---|
|
||||
| **Deadline** | When a task deadline is reached | Scheduled via background job (pgboss). If the deadline is already past when set, fires immediately. | Available |
|
||||
| **Assignment** | When you are assigned to a task | Immediate | Available |
|
||||
| **Mention** | When someone mentions you | Immediate | Planned |
|
||||
| **Comment** | When someone comments on your task | Immediate | Planned |
|
||||
| **Status change** | When a task status changes | Immediate | Planned |
|
||||
|
||||
## Delivery channels
|
||||
|
||||
Notifications can be sent through two channels (with email planned for the future):
|
||||
|
||||
| Channel | Description | Required configuration |
|
||||
|---|---|---|
|
||||
| **Push** | Native push notifications on iOS/Android via Firebase Cloud Messaging | `FIREBASE_CREDENTIALS_PATH` |
|
||||
| **In-app (WebSocket)** | Real-time delivery to the browser via Centrifugo | `CENTRIFUGO_API_URL`, `CENTRIFUGO_API_KEY`, `CENTRIFUGO_TOKEN_SECRET`, `CENTRIFUGO_PUBLIC_URL` |
|
||||
|
||||
Both channels are optional. If Firebase is not configured, push notifications are silently skipped. If Centrifugo is not configured, in-app real-time delivery is skipped. Notifications are always saved to the database regardless of channel availability.
|
||||
|
||||
## User preferences
|
||||
|
||||
Each user can control which notifications they receive and through which channels. Settings are available in **Account Settings > Notification Settings**.
|
||||
|
||||
Preferences follow an **opt-out model**: everything is enabled by default. Users explicitly disable what they do not want.
|
||||
|
||||
### Global and per-project settings
|
||||
|
||||
Preferences support two levels:
|
||||
|
||||
- **Global** applies to all projects
|
||||
- **Project overrides** apply to a specific project and are merged on top of global settings
|
||||
|
||||
For example, a user can enable push for all deadline notifications globally, but disable push for deadlines in a specific project.
|
||||
|
||||
### Deadline intervals (planned)
|
||||
|
||||
::callout{icon="i-lucide-construction" color="warning"}
|
||||
Deadline intervals are defined in the preferences structure but not yet active. Currently, deadline notifications fire once at the moment of the deadline. Multiple interval support is planned for a future release.
|
||||
::
|
||||
|
||||
The preferences structure supports the following intervals (minutes before the deadline):
|
||||
|
||||
| Interval | Description |
|
||||
|---|---|
|
||||
| `0` | At the moment of the deadline |
|
||||
| `15` | 15 minutes before |
|
||||
| `30` | 30 minutes before |
|
||||
| `60` | 1 hour before |
|
||||
| `1440` | 1 day before |
|
||||
|
||||
Each interval can be independently enabled or disabled.
|
||||
|
||||
## How it works
|
||||
|
||||
1. An event occurs (task created, deadline changed, assignees changed, etc.)
|
||||
2. **NotificationDispatcher** listens to the event bus and determines the notification type, recipients, and whether to send immediately or schedule a background job
|
||||
3. For deadlines, **DeadlineScheduler** creates a pgboss job that fires at the right time
|
||||
4. When it is time to deliver, **NotificationService** checks the user's preferences, saves the notification to the database, and sends it through enabled channels only
|
||||
5. **Providers** (FCMProvider, CentrifugoProvider) handle the actual delivery
|
||||
|
||||
## Viewing notifications
|
||||
|
||||
Click the bell icon in the sidebar to open the notification panel. From there you can:
|
||||
|
||||
- See all your notifications with type icons and timestamps
|
||||
- Click a notification to navigate to the related task
|
||||
- Mark individual notifications as read
|
||||
- Mark all notifications as read
|
||||
- Load older notifications via pagination
|
||||
|
||||
Notifications older than 1 day are automatically cleaned up by a daily background job.
|
||||
|
||||
## Configuration
|
||||
|
||||
See [Environment Variables](/docs/configuration/environment-variables#notifications) for the full list of notification-related variables.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/module/notifications` | Fetch notifications (cursor pagination) |
|
||||
| `PATCH` | `/module/notifications/read` | Mark a notification as read |
|
||||
| `PATCH` | `/module/notifications/read-all` | Mark all notifications as read |
|
||||
| `GET` | `/module/notifications/preferences` | Get user notification preferences |
|
||||
| `PUT` | `/module/notifications/preferences` | Save user notification preferences |
|
||||
| `GET` | `/module/notifications/connection-token` | Get WebSocket connection token |
|
||||
| `POST` | `/module/notifications/device/register` | Register a device for push notifications |
|
||||
| `POST` | `/module/notifications/device/unregister` | Unregister a device |
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: Configure webhooks in TaskView to receive real-time HTTP notifications when tasks are created, updated, deleted, or reassigned. Includes HMAC-SHA256 signature verification, automatic retries, and delivery history.
|
||||
navigation:
|
||||
icon: i-lucide-webhook
|
||||
---
|
||||
|
||||
Webhooks let you receive HTTP POST requests when events happen in your projects. Use them to integrate TaskView with external systems - CI/CD pipelines, Slack bots, custom dashboards, or any service that can accept HTTP requests.
|
||||
|
||||
## Supported events
|
||||
|
||||
| Event | When it fires |
|
||||
|---|---|
|
||||
| `task.created` | A new task is created in the project |
|
||||
| `task.updated` | A task is updated (description, status, priority, deadline, etc.) |
|
||||
| `task.deleted` | A task is deleted |
|
||||
| `task.assigneesChanged` | Task assignees are added or removed |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open a project in TaskView
|
||||
2. Right-click the project in the sidebar → **"Webhooks"**
|
||||
3. Click **"Add Webhook"**
|
||||
4. Enter the URL where you want to receive events
|
||||
5. Select which events to subscribe to
|
||||
6. Click **"Add"**
|
||||
7. Copy the secret and store it securely - it will not be shown again
|
||||
|
||||
## Payload format
|
||||
|
||||
Every webhook delivery is an HTTP POST with `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task.updated",
|
||||
"timestamp": "2026-03-22T12:00:00.000Z",
|
||||
"task": {
|
||||
"id": 123,
|
||||
"goalId": 774,
|
||||
"description": "Fix login bug",
|
||||
"complete": false,
|
||||
"statusId": 5,
|
||||
"priorityId": 2,
|
||||
"tags": [1, 3],
|
||||
"assignedUsers": [10, 22],
|
||||
"subtasks": []
|
||||
},
|
||||
"changes": {
|
||||
"statusId": 5
|
||||
},
|
||||
"initiatorId": 1
|
||||
}
|
||||
```
|
||||
|
||||
The `changes` field is only present on `task.updated` events and contains only the fields that changed.
|
||||
|
||||
## Signature verification
|
||||
|
||||
Every request includes an `X-Webhook-Signature` header with an HMAC-SHA256 signature of the request body:
|
||||
|
||||
```
|
||||
X-Webhook-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
|
||||
```
|
||||
|
||||
Always verify the signature before processing the payload. Example in Node.js:
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto')
|
||||
|
||||
function verifySignature(body, signature, secret) {
|
||||
const expected = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
return signature === expected
|
||||
}
|
||||
|
||||
// In your HTTP handler:
|
||||
const body = req.body // raw string, not parsed JSON
|
||||
const signature = req.headers['x-webhook-signature']
|
||||
const isValid = verifySignature(body, signature, YOUR_SECRET)
|
||||
```
|
||||
|
||||
::callout{icon="i-lucide-shield-alert" color="warning"}
|
||||
Never process webhook payloads without verifying the signature. Without verification, anyone who knows your webhook URL can send fake events.
|
||||
::
|
||||
|
||||
## Retries
|
||||
|
||||
If your server responds with a non-2xx status code or doesn't respond within 10 seconds, TaskView retries the delivery:
|
||||
|
||||
| Attempt | Delay |
|
||||
|---|---|
|
||||
| 1st retry | ~10 seconds |
|
||||
| 2nd retry | ~20 seconds |
|
||||
|
||||
After 3 total attempts (1 original + 2 retries), the delivery is marked as **failed**.
|
||||
|
||||
## Auto-deactivation
|
||||
|
||||
If a webhook accumulates **10 consecutive failed deliveries** (after all retries are exhausted), it is automatically deactivated. A single successful delivery resets the failure counter.
|
||||
|
||||
To reactivate a webhook, toggle it back on from the webhooks page. The failure counter is not reset automatically - the next successful delivery will reset it.
|
||||
|
||||
## Delivery history
|
||||
|
||||
The webhooks page shows delivery history for each webhook:
|
||||
- **Event** - which event was delivered
|
||||
- **Status** - success, failed, or pending
|
||||
- **HTTP code** - response status code from your server
|
||||
- **Attempts** - how many attempts were made
|
||||
- **Payload** - click to view the full JSON payload
|
||||
|
||||
Failed deliveries can be retried manually from the delivery history.
|
||||
|
||||
## Managing webhooks
|
||||
|
||||
From the webhooks page you can:
|
||||
- **Toggle** webhooks on/off
|
||||
- **Edit** the URL and subscribed events
|
||||
- **Test** - sends a test payload to verify connectivity
|
||||
- **Rotate secret** - generates a new secret (the old one stops working immediately)
|
||||
- **Delete** - removes the webhook and all delivery history
|
||||
- **View deliveries** - see delivery history with status filter
|
||||
|
||||
## Secret rotation
|
||||
|
||||
If your secret is compromised, rotate it:
|
||||
|
||||
1. Click the key icon on the webhook
|
||||
2. Confirm that you want to rotate
|
||||
3. Copy the new secret
|
||||
4. Update the secret in your receiving application
|
||||
|
||||
The old secret stops working immediately. Any in-flight deliveries signed with the old secret will fail signature verification on your end.
|
||||
|
||||
## Testing locally
|
||||
|
||||
You can use a simple Node.js script to test webhook deliveries:
|
||||
|
||||
```javascript
|
||||
const http = require('http')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const PORT = 4545
|
||||
const SECRET = 'your-secret-here'
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = []
|
||||
req.on('data', (chunk) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString()
|
||||
const signature = req.headers['x-webhook-signature'] || ''
|
||||
const expected = 'sha256=' + crypto
|
||||
.createHmac('sha256', SECRET)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
|
||||
console.log(signature === expected ? 'Valid' : 'INVALID')
|
||||
console.log(JSON.stringify(JSON.parse(body), null, 2))
|
||||
|
||||
res.writeHead(200)
|
||||
res.end('OK')
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(PORT, () => console.log(`Listening on :${PORT}`))
|
||||
```
|
||||
|
||||
Run with `node webhook-receiver.js` and set the webhook URL to `http://localhost:4545`.
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: API Tokens
|
||||
description: Create API tokens for programmatic access to TaskView. Tokens support permission scoping, project-level restrictions, and optional expiration.
|
||||
navigation:
|
||||
icon: i-lucide-key-round
|
||||
---
|
||||
|
||||
API tokens let you access the TaskView API without a browser session. Use them for scripts, CI/CD pipelines, bots, and any programmatic integration.
|
||||
|
||||
## Token format
|
||||
|
||||
Tokens use the prefix `tvk_` followed by 64 hex characters:
|
||||
|
||||
```
|
||||
tvk_a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
The full token is shown **only once** at creation. TaskView stores only the SHA-256 hash - if you lose the token, you'll need to create a new one.
|
||||
|
||||
## Creating a token
|
||||
|
||||
1. Go to **Account Settings** → **API Tokens**
|
||||
2. Click **"Create Token"**
|
||||
3. Enter a name (e.g. "CI pipeline", "Slack bot")
|
||||
4. Optionally restrict permissions and projects
|
||||
5. Optionally set an expiration date
|
||||
6. Click **"Create"**
|
||||
7. Copy the token immediately - it will not be shown again
|
||||
|
||||
## Authentication
|
||||
|
||||
Send the token in the `Authorization` header:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer tvk_a1b2c3d4..." \
|
||||
https://your-instance.com/module/tasks?goalId=1
|
||||
```
|
||||
|
||||
## Permission scoping
|
||||
|
||||
By default a token inherits all permissions of its owner. You can restrict this at creation:
|
||||
|
||||
- **Permissions** - select which operations the token can perform (e.g. only read tasks, only create tasks)
|
||||
- **Projects** - restrict the token to specific projects. If no projects are selected, the token has access to all projects the owner can access
|
||||
|
||||
Permissions are **intersected** with the user's RBAC role. A token cannot have more permissions than the user who created it. See [Roles and Permissions](/collaboration/roles-and-permissions) for details on how RBAC works.
|
||||
|
||||
### Available permission examples
|
||||
|
||||
| Permission | Description |
|
||||
|---|---|
|
||||
| `component_can_watch_content` | Read tasks and lists |
|
||||
| `component_can_add_tasks` | Create new tasks |
|
||||
| `task_can_edit_description` | Edit task descriptions |
|
||||
| `task_can_edit_status` | Change task status |
|
||||
| `task_can_delete` | Delete tasks |
|
||||
| `task_can_assign_users` | Assign users to tasks |
|
||||
|
||||
The full list of available permissions is returned by `GET /module/api-tokens/permissions`.
|
||||
|
||||
## Expiration
|
||||
|
||||
Tokens can optionally have an expiration date. After expiration, the token returns `401 Unauthorized`. Tokens without an expiration date are valid until manually revoked.
|
||||
|
||||
## Security
|
||||
|
||||
- Tokens **cannot manage other tokens** - all token management endpoints reject API token authentication
|
||||
- Only the SHA-256 hash is stored in the database
|
||||
- `lastUsedAt` is updated on each use for audit purposes
|
||||
- Tokens for blocked users are automatically rejected
|
||||
|
||||
## API reference
|
||||
|
||||
All endpoints require JWT authentication (not API token).
|
||||
|
||||
### List tokens
|
||||
|
||||
```
|
||||
GET /module/api-tokens
|
||||
```
|
||||
|
||||
Returns all tokens for the current user (without hashes).
|
||||
|
||||
### Create token
|
||||
|
||||
```
|
||||
POST /module/api-tokens
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "CI pipeline",
|
||||
"allowedPermissions": ["component_can_watch_content"],
|
||||
"allowedGoalIds": [1, 2],
|
||||
"expiresAt": "2026-12-31T23:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
All fields except `name` are optional. Returns the full plaintext token once.
|
||||
|
||||
### Delete token
|
||||
|
||||
```
|
||||
DELETE /module/api-tokens
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5
|
||||
}
|
||||
```
|
||||
|
||||
### List available permissions
|
||||
|
||||
```
|
||||
GET /module/api-tokens/permissions
|
||||
```
|
||||
|
||||
Returns permissions grouped by category.
|
||||
|
||||
## Usage example
|
||||
|
||||
Using the `taskview-api` package:
|
||||
|
||||
```bash
|
||||
npm install taskview-api axios
|
||||
```
|
||||
|
||||
```typescript
|
||||
import axios from 'axios'
|
||||
import { TvApi } from 'taskview-api'
|
||||
|
||||
const GOAL_ID = 1
|
||||
|
||||
const $axios = axios.create({
|
||||
baseURL: 'https://your-instance.com',
|
||||
headers: {
|
||||
Authorization: 'Bearer tvk_your_token_here',
|
||||
},
|
||||
})
|
||||
|
||||
const api = new TvApi($axios)
|
||||
|
||||
// Fetch all tasks in a project
|
||||
const tasks = await api.tasks.fetch({ goalId: GOAL_ID })
|
||||
console.log(`Found ${tasks.length} tasks`)
|
||||
|
||||
// Create a new task
|
||||
const newTask = await api.tasks.createTask({
|
||||
goalId: GOAL_ID,
|
||||
description: 'Task created via API',
|
||||
})
|
||||
console.log('Created task:', newTask.id)
|
||||
|
||||
// Update the task description
|
||||
await api.tasks.updateTask({
|
||||
id: newTask.id,
|
||||
description: 'Updated via API',
|
||||
})
|
||||
console.log('Task updated')
|
||||
|
||||
// Fetch projects
|
||||
const goals = await api.goals.fetchGoals()
|
||||
console.log('Projects:', goals.map((g) => g.name))
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Sessions & Devices
|
||||
description: Manage active sessions in TaskView. View logged-in devices, close individual sessions, or sign out of all devices at once.
|
||||
navigation:
|
||||
icon: i-lucide-monitor-smartphone
|
||||
---
|
||||
|
||||
TaskView tracks every login as a separate session. You can see all active sessions, identify which device each session belongs to, and close sessions remotely.
|
||||
|
||||
## How sessions work
|
||||
|
||||
When you log in from any device (browser, mobile app), TaskView creates a session record with:
|
||||
|
||||
- **Device name** - automatically parsed from User-Agent (e.g. "Chrome 120, macOS", "Safari 17, iPhone")
|
||||
- **IP address** - the IP used at login time
|
||||
- **Created at** - when the session was created
|
||||
- **Last used** - when the session was last active
|
||||
|
||||
The JWT token issued at login contains the session ID. On each request, TaskView verifies that the session still exists - if it's been revoked, the token is rejected.
|
||||
|
||||
## Viewing sessions
|
||||
|
||||
1. Go to **Account** → **Sessions**
|
||||
2. See all active sessions with device name, IP, and timestamps
|
||||
3. Your current session is marked
|
||||
|
||||
## Closing a session
|
||||
|
||||
Click the close button on any session to revoke it. The user on that device will be signed out on their next request.
|
||||
|
||||
You cannot close your current session from this page - use the regular logout instead.
|
||||
|
||||
## Closing all other sessions
|
||||
|
||||
Click **"Close all other sessions"** to sign out of every device except the one you're currently using. Useful if you suspect unauthorized access.
|
||||
|
||||
## API reference
|
||||
|
||||
All endpoints require JWT authentication. API tokens cannot manage sessions.
|
||||
|
||||
### List sessions
|
||||
|
||||
```
|
||||
GET /module/sessions
|
||||
```
|
||||
|
||||
Returns all active sessions for the current user. Each session includes an `isCurrent` flag.
|
||||
|
||||
### Close a session
|
||||
|
||||
```
|
||||
DELETE /module/sessions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42
|
||||
}
|
||||
```
|
||||
|
||||
Returns `400` if you try to close the current session.
|
||||
|
||||
### Close all other sessions
|
||||
|
||||
```
|
||||
DELETE /module/sessions/all
|
||||
```
|
||||
|
||||
Closes all sessions except the current one.
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Integrations
|
||||
icon: false
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user