Compare commits

..

27 Commits

Author SHA1 Message Date
Nikolai Giman ff539f00f5 Merge pull request #53 from Gimanh/fix/login-by-code
fix: login by code
2026-05-16 14:58:18 +02:00
Nikolai Giman eede789d54 fix: login by code 2026-05-16 14:56:27 +02:00
Nikolai Giman c532f607aa Merge pull request #52 from Gimanh/fix/recovery-password
fix: password recovery
2026-05-16 13:54:18 +02:00
Nikolai Giman 791b9d1c6e fix: password recovery 2026-05-16 13:53:32 +02:00
Nikolai Giman da82f3ae0f Merge pull request #51 from Gimanh/feat/locale-de
chore: lint
2026-05-16 11:02:50 +02:00
Nikolai Giman 96c40d5f49 feat: new locale Deutsch 2026-05-16 11:01:54 +02:00
Nikolai Giman dbd5253423 chore: lint 2026-05-16 09:56:51 +02:00
Nikolai Giman c0c0a49c0d Merge pull request #50 from Gimanh/feat/timetracking
wip: time tracking
2026-05-16 09:13:55 +02:00
Nikolai Giman f0fed9d68b wip: TT for specific goal 2026-05-16 09:12:13 +02:00
Nikolai Giman ee67342313 wip: fixed permissions and UI 2026-05-15 17:52:29 +02:00
Nikolai Giman a4bb9de09f wip: check permissions for TT component 2026-05-15 10:18:11 +02:00
Nikolai Giman 0d01b4d532 wip: tests and permission descriptions 2026-05-14 23:40:22 +02:00
Nikolai Giman a5446ea825 wip: tests for MCP 2026-05-14 23:22:10 +02:00
Nikolai Giman ec50b31642 wip: time tracking page and tests 2026-05-14 22:46:29 +02:00
Nikolai Giman 1706a3124b wip: webhooks and fixes 2026-05-12 22:02:13 +02:00
Nikolai Giman 6cf26fdd61 feat: webhooks for time tracking 2026-05-12 16:54:00 +02:00
Nikolai Giman d081436519 wip: time tracking 2026-05-12 00:03:56 +02:00
Nikolai Giman 40e54966a8 Merge pull request #48 from Gimanh/feat/analytics
feat: analytics
2026-05-09 23:42:28 +02:00
Nikolai Giman 4527ae6c4b fix: locales and permissions for drilldown 2026-05-09 21:34:06 +02:00
Nikolai Giman 9df7b4a7cd fix: indexes 2026-05-09 17:28:21 +02:00
Nikolai Giman 5afcc2d8ec fix: goals archive exclude and permissions check for drilldown 2026-05-08 22:56:01 +02:00
Nikolai Giman d2c9bee0d3 Merge pull request #49 from Gimanh/chore/mobile-version
chore: new mobile version
2026-05-04 00:09:54 +02:00
Nikolai Giman c3ff29501b chore: new mobile version 2026-05-04 00:09:27 +02:00
Nikolai Giman f791822c79 fix: only owner can watch all analytics 2026-05-03 15:35:53 +02:00
Nikolai Giman 344526b1b2 fix: handle drill-down correctly 2026-04-27 01:06:22 +02:00
Nikolai Giman 95f024275c fix: ui issues 2026-04-27 00:55:54 +02:00
Nikolai Giman fd8ea2e328 feat: analytics 2026-04-27 00:39:30 +02:00
174 changed files with 15172 additions and 132 deletions
+15 -3
View File
@@ -5,6 +5,11 @@
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
## Apps
* [Docs](https://taskview.tech/docs/)
* [Web](https://app.taskview.tech/)
@@ -134,11 +139,18 @@ Make sure the image versions match the version defined in the root package.json.
## Roadmap
- Plugin / extension system
- [X] Migrate to NuxtUI or similar ui library
- Enterprise SSO and identity integrations
- [X] Enterprise SSO and identity integrations
- [X] Redesign
- Desktop version
- [X] API tokens
- [X] Webhooks
- [X] MCP server
- [X] Notifications
- [X] Analytics
- [X] API client
- [ ] Desktop version
- [ ] Plugin / extension system
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
+1
View File
@@ -71,6 +71,7 @@
"pino": "^9.4.0",
"rotating-file-stream": "^3.2.5",
"semver": "^7.6.3",
"taskview-api": "workspace:^",
"taskview-db-schemas": "workspace:^",
"terser": "^5.36.0",
"ua-parser-js": "^2.0.9",
+4 -25
View File
@@ -1,25 +1,13 @@
import cors from 'cors';
import express, { type Request, type Response } from 'express';
import helmet from 'helmet';
import { appUserMiddleware } from './middlewares/app-user-middleware';
import { corsMiddleware } from './middlewares/cors';
import errorHandler from './middlewares/error-handler';
import routes from './routes';
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
import cookieParser from 'cookie-parser';
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
const allow = new Set([
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
// default allowed origins for official TaskView apps
"https://app.taskview.tech",
"https://taskview.handscream.com",
"capacitor://taskview.handscream.com",
"capacitor://app.taskview.tech",
"https://appleid.apple.com"
]),
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
]);
export default class App {
public app: express.Application;
public port: number;
@@ -52,19 +40,9 @@ export default class App {
next();
});
this.app.use(cookieParser());
this.app.use(appUserMiddleware);
this.app.use(cors({
credentials: true,
origin(origin, cb) {
if (!origin || origin === 'null') return cb(null, true);
if (allow.has(origin)) return cb(null, true);
return cb(new Error(`CORS blocked origin: ${origin}`), false);
},
}));
this.app.use(helmet());
this.app.use(corsMiddleware);
this.app.use(cookieParser());
this.app.use(express.json({
verify: (req: any, _res, buf) => {
// Store raw body for webhook signature verification github and gitlab integrations
@@ -74,6 +52,7 @@ export default class App {
},
}));
this.app.use(express.urlencoded({ extended: true }));
this.app.use(appUserMiddleware);
}
private initializeRoutes() {
+6
View File
@@ -11,7 +11,9 @@ import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsMana
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
import { OrganizationManager } from '../tv-modules/organizations/OrganizationManager';
import { SsoManager } from '../tv-modules/sso/SsoManager';
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
import { TasksManager } from '../tv-modules/tasks/TasksManager';
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -37,6 +39,8 @@ export class AppUser {
public readonly notificationsManager: NotificationsManager;
public readonly organizationManager: OrganizationManager;
public readonly ssoManager: SsoManager;
public readonly analyticsManager: AnalyticsManager;
public readonly timeTrackingManager: TimeTrackingManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -55,6 +59,8 @@ export class AppUser {
this.notificationsManager = new NotificationsManager(this);
this.organizationManager = new OrganizationManager(this);
this.ssoManager = new SsoManager(this);
this.analyticsManager = new AnalyticsManager(this);
this.timeTrackingManager = new TimeTrackingManager(this);
}
getTokenId(): number | undefined {
+6
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events';
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
import { $logger } from '../modules/logget';
export interface AppEvents {
@@ -10,6 +11,11 @@ export interface AppEvents {
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
'time-entry.stopped': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number; durationSeconds: number };
'time-entry.created': { entry: TimeEntryWithUser; initiatorId: number };
'time-entry.updated': { entry: TimeEntryWithUser; changes: Record<string, unknown>; initiatorId: number };
'time-entry.deleted': { entryId: number; taskId: number; goalId: number; userId: number; initiatorId: number };
}
type EventName = keyof AppEvents;
+28
View File
@@ -67,4 +67,32 @@ export class GoalPermissionsFetcher {
async getCheckerForGoal(goalId: number): Promise<GoalPermissionsChecker> {
return await this.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL);
}
async getAccessibleGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
if (permissions.length === 0) return [];
const tokenPerms = this.user.getTokenPermissions();
const effectivePermissions = tokenPerms && tokenPerms.length > 0
? permissions.filter((p) => tokenPerms.includes(p))
: permissions;
if (effectivePermissions.length === 0) return [];
const userData = this.user.getUserData();
if (!userData) return [];
let goalIds = await this.goalPermissionsRepository.fetchGoalIdsWithAnyPermission({
userId: userData.id,
email: userData.email,
organizationId,
permissionNames: effectivePermissions,
});
const allowedGoalIds = this.user.getAllowedGoalIds();
if (allowedGoalIds && allowedGoalIds.length > 0) {
goalIds = goalIds.filter((id) => allowedGoalIds.includes(id));
}
return goalIds;
}
}
+66 -3
View File
@@ -1,5 +1,15 @@
import { and, eq, exists, inArray, or } from 'drizzle-orm';
import {
CollaborationPermissionsToRoleSchema,
CollaborationRolesSchema,
CollaborationUsersSchema,
CollaborationUsersToGoalsSchema,
CollaborationUsersToRolesSchema,
GoalsSchema,
PermissionsSchema,
} from 'taskview-db-schemas';
import { Database } from '../modules/db';
import type { GoalPermissionItemsFromDb } from '../types/auth.types';
import type { FetchGoalIdsWithAnyPermissionParams, GoalPermissionItemsFromDb } from '../types/auth.types';
import type { GoalItemInDb } from '../types/goal.type';
import type { ListItemInDb } from '../types/lists.types';
import type { TaskItemInDb } from '../types/tasks.types';
@@ -35,12 +45,12 @@ export class GoalPermissionsRepository {
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
query = `select name as "permissionName", id as "permissionId" from tv_auth.permissions;`;
} else {
query = `select p.name as "permissionName", p.id as "permissionId"
query = `select p.name as "permissionName", p.id as "permissionId"
from collaboration.users cu
left join collaboration.users_to_goals utg on cu.id = utg.user_id
left join tasks.goals tg on utg.goal_id = tg.id
left join collaboration.users_to_roles utr on utr.user_id = cu.id
left join collaboration.roles rol on utr.role_id = rol.id
left join collaboration.roles rol on utr.role_id = rol.id and rol.goal_id = tg.id
left join collaboration.permissions_to_role ptr on rol.id = ptr.role_id
left join tv_auth.permissions p on ptr.permission_id = p.id
where email = $1 and tg.id = $2 and p.name is not null and p.id is not null;`;
@@ -59,4 +69,57 @@ export class GoalPermissionsRepository {
}
return null;
}
async fetchGoalIdsWithAnyPermission(params: FetchGoalIdsWithAnyPermissionParams): Promise<number[]> {
if (params.permissionNames.length === 0) return [];
const permissionSubquery = this.db.dbDrizzle
.select({ one: CollaborationUsersSchema.id })
.from(CollaborationUsersSchema)
.innerJoin(
CollaborationUsersToGoalsSchema,
and(
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id),
eq(CollaborationUsersToGoalsSchema.goalId, GoalsSchema.id),
),
)
.innerJoin(
CollaborationUsersToRolesSchema,
eq(CollaborationUsersToRolesSchema.userId, CollaborationUsersSchema.id),
)
.innerJoin(
CollaborationRolesSchema,
and(
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
eq(CollaborationRolesSchema.goalId, GoalsSchema.id),
),
)
.innerJoin(
CollaborationPermissionsToRoleSchema,
eq(CollaborationPermissionsToRoleSchema.roleId, CollaborationRolesSchema.id),
)
.innerJoin(
PermissionsSchema,
eq(PermissionsSchema.id, CollaborationPermissionsToRoleSchema.permissionId),
)
.where(
and(
eq(CollaborationUsersSchema.email, params.email),
inArray(PermissionsSchema.name, params.permissionNames),
),
);
const result = await this.db.dbDrizzle
.select({ id: GoalsSchema.id })
.from(GoalsSchema)
.where(
and(
eq(GoalsSchema.organizationId, params.organizationId),
eq(GoalsSchema.archive, 0),
or(eq(GoalsSchema.owner, params.userId), exists(permissionSubquery)),
),
);
return result.map((r) => r.id).filter((id): id is number => Number.isInteger(id));
}
}
+2
View File
@@ -3,11 +3,13 @@ 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';
import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTrackingDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
new TimeTrackingDispatcher(),
];
export function registerAllEventHandlers() {
@@ -6,6 +6,11 @@ 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) => {
if (req.method === 'OPTIONS') {
req.appUser = new AppUser();
return next();
}
const token = req.headers['authorization']?.split(' ')[1];
if (token && token.startsWith(TOKEN_PREFIX)) {
+22
View File
@@ -0,0 +1,22 @@
import cors from 'cors';
const allow = new Set([
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
'https://app.taskview.tech',
'https://taskview.handscream.com',
'capacitor://taskview.handscream.com',
'capacitor://app.taskview.tech',
'https://appleid.apple.com',
]),
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
]);
export const corsMiddleware = cors({
credentials: true,
maxAge: 600,
origin(origin, cb) {
if (!origin || origin === 'null') return cb(null, true);
if (allow.has(origin)) return cb(null, true);
return cb(new Error(`CORS blocked origin: ${origin}`), false);
},
});
+13 -5
View File
@@ -1,18 +1,26 @@
import type { NextFunction, Request, Response } from 'express';
import { corsMiddleware } from './cors';
interface CustomError extends Error {
status?: number;
}
const errorHandler = (err: CustomError, _req: Request, res: Response, _next: NextFunction) => {
const sendError = (err: CustomError, res: Response) => {
const statusCode = err.status || 500;
const isProduction = process.env.NODE_ENV === 'production';
const userMessage = isProduction ? 'Internal Server Error' : err.message || 'Something went wrong';
res.status(statusCode).json({ message: userMessage });
};
res.status(statusCode).json({
message: userMessage,
});
const errorHandler = (err: CustomError, req: Request, res: Response, _next: NextFunction) => {
// Re-run the same cors middleware so error responses get CORS headers even when
// the original pass was bypassed (error thrown before it ran). Reusing the actual
// middleware preserves the allowlist policy — disallowed origins still get no headers.
if (res.getHeader('Access-Control-Allow-Origin')) {
sendError(err, res);
return;
}
corsMiddleware(req, res, () => sendError(err, res));
};
export default errorHandler;
+78
View File
@@ -472,5 +472,83 @@
"description": [
"Create personal organizations for users without any organization membership"
]
},
"38": {
"version": "1.45.0",
"name": "Release 1.45.0",
"releaseDate": "20260425",
"scripts": [
"/1.45.0/0.analytics_indexes.sql"
],
"description": [
"Add composite indexes on tasks.tasks for analytics queries"
]
},
"39": {
"version": "1.46.0",
"name": "Release 1.46.0",
"releaseDate": "20260426",
"scripts": [
"/1.46.0/0.add-analytics-permission.sql"
],
"description": [
"Add analytics_can_view permission for project-level analytics access"
]
},
"40": {
"version": "1.47.0",
"name": "Release 1.47.0",
"releaseDate": "20260509",
"scripts": [
"/1.47.0/0.analytics_indexes_v2.sql"
],
"description": [
"Add indexes on tasks_auth.task_assignee, collaboration.users_to_goals, tasks.task_relations(to_task_id) for analytics joins",
"Add partial index on tasks.goals(organization_id) WHERE archive = 0 for analytics goal lookup",
"Add partial index on tasks.tasks(goal_id) WHERE complete IS NOT TRUE for open-task analytics queries"
]
},
"41": {
"version": "1.48.0",
"name": "Release 1.48.0",
"releaseDate": "20260511",
"scripts": [
"/1.48.0/0.create-time-entries.sql",
"/1.48.0/1.create-time-entries-history.sql",
"/1.48.0/2.alter-organizations-add-autostop.sql",
"/1.48.0/3.add-timetracking-permissions.sql",
"/1.48.0/all-triggers.sql"
],
"description": [
"Added time-tracking module: tasks.time_entries with one-active-timer-per-user partial unique index",
"Added history.time_entries audit table with FK ON DELETE CASCADE to time_entries (history removed with the entry)",
"Added time_tracking_autostop_hours column to tv_auth.organizations (default 24, NULL = disabled)",
"Added timetracking_can_log and timetracking_can_manage_all goal-level permissions (granted to new projects only, matching the 1.46.0 analytics convention)",
"all-triggers.sql: updated add_roles_and_permissions function with new permissions; added log_changes_time_entries trigger (BEFORE UPDATE writes JSONB snapshot to history.time_entries)"
]
},
"42": {
"version": "1.49.0",
"name": "Release 1.49.0",
"releaseDate": "20260512",
"scripts": [
"/1.49.0/0.time_entries_report_indexes.sql"
],
"description": [
"Added compound indexes on tasks.time_entries (goal_id, started_at, user_id) and (goal_id, started_at, task_id) for time-tracking report aggregations",
"Added partial index on tasks.time_entries(goal_id, started_at) WHERE billable = TRUE for billable-only reports"
]
},
"43": {
"version": "1.50.0",
"name": "Release 1.50.0",
"releaseDate": "20260515",
"scripts": [
"/1.50.0/0.update-timetracking-permissions-descriptions.sql"
],
"description": [
"Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log",
"Tightened timetracking_can_log description: it grants only start/stop/createManual, NOT edit/delete (including own entries). Edit/delete now requires timetracking_can_manage_all — see can-access-time-entry middleware change."
]
}
}
@@ -134,3 +134,10 @@ values ('task_can_assign_users',
('task_can_watch_assigned_users',
'User can see assigned users to tasks',
4);
insert into tv_auth.permissions (name, description, permission_group, description_locales)
values ('analytics_can_view',
'User can view analytics for this goal',
2,
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb)
on conflict (name) do nothing;
@@ -0,0 +1,14 @@
drop index if exists tasks.idx_tasks_goal_id_date_creation;
create index idx_tasks_goal_id_date_creation on tasks.tasks (goal_id, date_creation);
drop index if exists tasks.idx_tasks_goal_id_date_complete;
create index idx_tasks_goal_id_date_complete on tasks.tasks (goal_id, date_complete) where complete = true;
drop index if exists tasks.idx_tasks_goal_id_end_date_open;
create index idx_tasks_goal_id_end_date_open on tasks.tasks (goal_id, end_date) where complete is not true;
drop index if exists tasks.idx_tasks_goal_id_edit_date_open;
create index idx_tasks_goal_id_edit_date_open on tasks.tasks (goal_id, edit_date) where complete is not true;
drop index if exists tasks.idx_tasks_goal_id_transaction_type;
create index idx_tasks_goal_id_transaction_type on tasks.tasks (goal_id, transaction_type) where amount is not null;
@@ -0,0 +1,8 @@
insert into tv_auth.permissions (name, description, permission_group, description_locales)
values (
'analytics_can_view',
'User can view analytics for this goal',
2,
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb
)
on conflict (name) do nothing;
@@ -0,0 +1,20 @@
drop index if exists tasks_auth.idx_task_assignee_task_id;
create index idx_task_assignee_task_id on tasks_auth.task_assignee (task_id);
drop index if exists tasks_auth.idx_task_assignee_user_task;
create index idx_task_assignee_user_task on tasks_auth.task_assignee (collab_user_id, task_id);
drop index if exists collaboration.idx_users_to_goals_user_goal;
create index idx_users_to_goals_user_goal on collaboration.users_to_goals (user_id, goal_id);
drop index if exists collaboration.idx_users_to_goals_goal_id;
create index idx_users_to_goals_goal_id on collaboration.users_to_goals (goal_id);
drop index if exists tasks.idx_task_relations_to_task;
create index idx_task_relations_to_task on tasks.task_relations (to_task_id, from_task_id);
drop index if exists tasks.idx_goals_org_active;
create index idx_goals_org_active on tasks.goals (organization_id) where archive = 0;
drop index if exists tasks.idx_tasks_goal_id_open;
create index idx_tasks_goal_id_open on tasks.tasks (goal_id) where complete is not true;
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS tasks.time_entries (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP NULL,
duration_seconds INTEGER NULL,
description VARCHAR(500) NULL,
source SMALLINT NOT NULL DEFAULT 0,
billable BOOLEAN NOT NULL DEFAULT TRUE,
auto_stopped BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT time_entries_end_after_start CHECK (ended_at IS NULL OR ended_at > started_at),
CONSTRAINT time_entries_duration_non_negative CHECK (duration_seconds IS NULL OR duration_seconds >= 0)
);
CREATE INDEX IF NOT EXISTS idx_time_entries_user_started
ON tasks.time_entries(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started
ON tasks.time_entries(goal_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_time_entries_task
ON tasks.time_entries(task_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_entries_active_per_user
ON tasks.time_entries(user_id) WHERE ended_at IS NULL;
@@ -0,0 +1,11 @@
CREATE SCHEMA IF NOT EXISTS history;
CREATE TABLE IF NOT EXISTS history.time_entries (
id SERIAL PRIMARY KEY,
entry_id INTEGER NOT NULL REFERENCES tasks.time_entries(id) ON DELETE CASCADE,
edit_date TIMESTAMP NOT NULL DEFAULT NOW(),
entry JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_history_time_entries_entry
ON history.time_entries(entry_id, edit_date DESC);
@@ -0,0 +1,2 @@
ALTER TABLE tv_auth.organizations
ADD COLUMN IF NOT EXISTS time_tracking_autostop_hours INTEGER DEFAULT 24;
@@ -0,0 +1,21 @@
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES
(
'timetracking_can_view',
'View all time entries on this project — both own and other members''',
2,
'{"en": "View time entries. User can see all time entries on this project — both own and other members''. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Логировать и редактировать нельзя."}'::jsonb
),
(
'timetracking_can_log',
'Start/stop timer and create/edit/delete OWN time entries on this project',
2,
'{"en": "Track time. User can start/stop the timer, add manual entries, and edit/delete OWN time entries. Does not grant viewing the project log or managing others'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер, добавлять записи вручную и редактировать/удалять СВОИ записи. Не даёт права просматривать журнал проекта и управлять чужими записями."}'::jsonb
),
(
'timetracking_can_manage_all',
'Full time-tracking access: log own time + view and edit/delete entries of any project member',
2,
'{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника. Включает права на просмотр и ведение времени."}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -0,0 +1,635 @@
--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',
'timetracking_can_view',
'timetracking_can_manage_all'
);
-- 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',
'timetracking_can_view',
'timetracking_can_log'
);
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();
--21.
--Trigger for logging changes in time_entries to history table
create or replace function tasks.log_changes_time_entries()
returns trigger as
$body$
begin
insert into history.time_entries (entry_id, edit_date, entry)
values (old.id, now(), to_jsonb(old));
new.edited_at = now();
return new;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_time_entries on tasks.time_entries;
create trigger trigger_log_changes_time_entries
before update
on tasks.time_entries
for each row
execute procedure tasks.log_changes_time_entries();
@@ -0,0 +1,9 @@
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_user
ON tasks.time_entries(goal_id, started_at DESC, user_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_task
ON tasks.time_entries(goal_id, started_at DESC, task_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_billable_goal_started
ON tasks.time_entries(goal_id, started_at DESC)
WHERE billable = TRUE;
@@ -0,0 +1,17 @@
UPDATE tv_auth.permissions
SET
description = 'View all time entries; exposes emails of contributors via the entry log',
description_locales = '{"en": "View time entries. User can see all time entries on this project — both own and other members''. Also exposes the emails of all contributors who have logged time on this project. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Также видны email-адреса всех участников, логировавших время. Логировать и редактировать нельзя."}'::jsonb
WHERE name = 'timetracking_can_view';
UPDATE tv_auth.permissions
SET
description = 'Start/stop timer and create manual entries; editing/deleting requires manage_all',
description_locales = '{"en": "Log time. User can start/stop the timer and add manual entries on this project. Cannot edit or delete entries (including own) — editing requires manage_all permission. Does not grant viewing other members'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер и добавлять записи вручную в этом проекте. Редактировать и удалять записи (даже свои) нельзя — для этого нужно право управления. Не даёт права просматривать записи других участников."}'::jsonb
WHERE name = 'timetracking_can_log';
UPDATE tv_auth.permissions
SET
description = 'Full time-tracking access: log, view, edit, delete entries; exposes contributor emails',
description_locales = '{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member (including own). Also exposes the emails of all contributors who have logged time on this project. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника (включая свои). Также видны email-адреса всех участников, логировавших время. Включает права на просмотр и ведение времени."}'::jsonb
WHERE name = 'timetracking_can_manage_all';
+12 -1
View File
@@ -8,13 +8,24 @@ export class Database {
public dbDrizzle: ReturnType<typeof drizzle>;
private constructor() {
const poolMaxRaw = Number(process.env.DB_POOL_MAX);
const poolMax = Number.isFinite(poolMaxRaw) && poolMaxRaw > 0 ? poolMaxRaw : 20;
this.pool = new Pool({
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!,
max: poolMax,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
this.pool.on('connect', (client) => {
client.query("SET TIME ZONE 'UTC'").catch((err) => {
console.error('Failed to set session timezone to UTC:', err);
});
});
this.dbDrizzle = drizzle({ client: this.pool, casing: 'camelCase' });
@@ -48,7 +59,7 @@ export class Database {
try {
const res = await client.query(text, params);
const duration = Date.now() - start;
$logger.info('executed query', { text, duration, rows: res.rowCount });
$logger.info({ text, duration, rows: res.rowCount }, 'executed query');
// console.log('executed query', { text, duration, rows: res.rowCount });
return res;
} catch (err) {
+4
View File
@@ -16,6 +16,8 @@ import TasksRoutes from '../tv-modules/tasks/TasksRoutes';
import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes';
import SsoRoutes from '../tv-modules/sso/SsoRoutes';
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -38,6 +40,8 @@ const routes: Record<string, RoutableConstructor> = {
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
'/module/sso': SsoRoutes,
'/module/analytics': AnalyticsRoutes,
'/module/time-tracking': TimeTrackingRoutes,
'/scim/v2': ScimRoutes,
};
@@ -0,0 +1,106 @@
import { type } from 'arktype'
import type { NextFunction, Request, Response } from 'express'
import type { AnalyticsScope } from 'taskview-api'
import { $logger } from '../../modules/logget'
import { parseDrillDownMeta, resolveRange } from './helpers'
import { AnalyticsDrillDownArkType, AnalyticsFetchSectionsArkType } from './types'
export class AnalyticsController {
fetchSections = async (req: Request, res: Response, next: NextFunction) => {
const out = AnalyticsFetchSectionsArkType(req.query)
if (out instanceof type.errors) {
$logger.warn(`analytics: validation failed: ${out.summary}`)
return res.status(400).send(out.summary)
}
let scope: AnalyticsScope
if (out.scope === 'project') {
if (out.goalId === undefined) {
return res.status(400).send('goalId is required for project scope')
}
scope = { kind: 'project', goalId: out.goalId }
} else {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
if (!range) return res.status(400).send('invalid range')
const sectionIds = out.sections
? out.sections.split(',').map(s => s.trim()).filter(Boolean)
: undefined
try {
const data = await req.appUser.analyticsManager.buildSections({
scope,
organizationId: out.organizationId,
period: out.period,
range,
sectionIds,
})
return res.tvJson(data)
} catch (err) {
$logger.error({
err,
userId: req.appUser.getUserData()?.id,
organizationId: out.organizationId,
scope,
period: out.period,
}, 'Analytics fetchSections failed')
return next(err)
}
}
fetchDrillDown = async (req: Request, res: Response, next: NextFunction) => {
const sectionId = req.params.sectionId
if (!sectionId) return res.status(400).send('missing sectionId')
const out = AnalyticsDrillDownArkType(req.query)
if (out instanceof type.errors) {
$logger.warn(`analytics drill-down: validation failed: ${out.summary}`)
return res.status(400).send(out.summary)
}
let scope: AnalyticsScope
if (out.scope === 'project') {
if (out.goalId === undefined) {
return res.status(400).send('goalId is required for project scope')
}
scope = { kind: 'project', goalId: out.goalId }
} else {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
if (!range) return res.status(400).send('invalid range')
const meta = parseDrillDownMeta(out.meta)
const index = Math.min(out.index ?? 0, 10000)
try {
const data = await req.appUser.analyticsManager.drillDown({
sectionId,
scope,
organizationId: out.organizationId,
period: out.period,
range,
arg: {
bucket: out.bucket ?? '',
index,
datasetId: out.datasetId ?? '',
meta,
},
})
return res.tvJson(data)
} catch (err) {
$logger.error({
err,
sectionId,
userId: req.appUser.getUserData()?.id,
organizationId: out.organizationId,
scope,
}, 'Analytics fetchDrillDown failed')
return next(err)
}
}
}
@@ -0,0 +1,169 @@
import type {
AnalyticsAvailableGoal,
AnalyticsDrillDownResponse,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionsResponse,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
import { $logger } from '../../modules/logget'
import { GoalPermissions } from '../../types/auth.types'
import { AnalyticsRepository } from './AnalyticsRepository'
import { SectionRegistry } from './sections/SectionRegistry'
import type {
AnalyticsArgBuildSections,
AnalyticsArgDrillDown,
BuilderContext,
SectionBuilder,
} from './types'
export class AnalyticsManager {
public readonly repository: AnalyticsRepository
private readonly registry: SectionRegistry
private readonly user: AppUser
constructor(user: AppUser) {
this.user = user
this.repository = new AnalyticsRepository()
this.registry = new SectionRegistry()
}
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
}
async getDrillDownGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [
GoalPermissions.ANALYTICS_CAN_VIEW,
GoalPermissions.TASKS_CAN_WATCH_DETAILS,
])
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
const ctx: BuilderContext = {
appUser: this.user,
scope,
period,
range,
accessibleGoalIds,
repository: this.repository,
}
const builders = this.registry.filterByIds(sectionIds)
const eligible = builders.filter(b => this.isBuilderEligible(b, scope))
const settled = await Promise.allSettled(eligible.map(b => b.build(ctx)))
const sections: AnalyticsSection[] = []
const failedSectionIds: string[] = []
settled.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) {
sections.push(r.value)
} else if (r.status === 'rejected') {
const sectionId = eligible[i].id
failedSectionIds.push(sectionId)
$logger.error({
err: r.reason,
sectionId,
userId: this.user.getUserData()?.id,
organizationId,
}, 'Analytics section failed')
}
})
const availableGoals = await this.fetchAvailableGoals(allAccessible)
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
sections,
availableGoals,
failedSectionIds,
}
}
async drillDown(params: AnalyticsArgDrillDown): Promise<AnalyticsDrillDownResponse> {
const { sectionId, scope, organizationId, period, range, arg } = params
const builder = this.registry.get(sectionId)
if (!builder || !builder.drillDown) {
return { sectionId, tasks: [], total: 0 }
}
const aggregateGoalIds = this.narrowToScope(
await this.getAccessibleGoalIds(organizationId),
scope,
)
const accessibleGoalIds = this.narrowToScope(
await this.getDrillDownGoalIds(organizationId),
scope,
)
if (aggregateGoalIds.length > 0 && accessibleGoalIds.length === 0) {
return { sectionId, tasks: [], total: 0, denied: true }
}
const ctx: BuilderContext = {
appUser: this.user,
scope,
period,
range,
accessibleGoalIds,
repository: this.repository,
}
const tasks = await builder.drillDown(ctx, arg).catch((err) => {
$logger.error(`Analytics drill-down ${sectionId} failed: ${err}`)
return []
})
return { sectionId, tasks, total: tasks.length }
}
private async fetchGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
const userData = this.user.getUserData()
if (!userData?.id || !userData?.email) return []
const ids = await this.user.organizationManager.isCurrentUserOrgOwner(organizationId)
? await this.repository.fetchAllGoalIdsInOrg(organizationId)
: await this.repository.fetchGoalIdsWithPermissions(
userData.id,
userData.email,
organizationId,
permissions,
)
return this.applyTokenFilter(ids)
}
private applyTokenFilter(ids: number[]): number[] {
const tokenAllowed = this.user.getAllowedGoalIds()
if (tokenAllowed && tokenAllowed.length > 0) {
return ids.filter(id => tokenAllowed.includes(id))
}
return ids
}
private narrowToScope(allAccessible: number[], scope: AnalyticsScope): number[] {
if (scope.kind === 'project') {
return allAccessible.includes(scope.goalId) ? [scope.goalId] : []
}
return allAccessible
}
private isBuilderEligible(builder: SectionBuilder, scope: AnalyticsScope): boolean {
if (builder.requiresGoalScope && scope.kind !== 'project') return false
return true
}
private async fetchAvailableGoals(goalIds: number[]): Promise<AnalyticsAvailableGoal[]> {
if (goalIds.length === 0) return []
return await this.repository.getGoalsForIds(goalIds)
}
}
@@ -0,0 +1,808 @@
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
import { GoalsSchema } from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import { toIntArraySql } from './helpers'
import type {
ActiveProjectsSectionRow,
AgingOpenTasksSectionRow,
AmountCoverageKpiRow,
BlockedByDependenciesSectionRow,
CompletedTasksKpiRow,
CreatedTasksKpiRow,
CycleTimeHistogramSectionRow,
CycleTimeKpiRow,
CycleTimePerProjectSectionRow,
IncomeExpenseMonthSectionRow,
IncomeExpensePerProjectSectionRow,
NetProfitKpiRow,
OverdueByAgeSectionRow,
OverdueKpiRow,
PlannedExpenseKpiRow,
PlannedIncomeKpiRow,
PriorityMixOverTimeSectionRow,
StaleTasksSectionRow,
StatusDistributionSectionRow,
ThroughputSectionRow,
TimeInKanbanStatusSectionRow,
TopProjectsByAmountSectionRow,
TotalExpenseKpiRow,
TotalIncomeKpiRow,
WorkloadByAssigneeSectionRow,
} from './sections/row.types'
import type { AnalyticsRange, DrillDownTaskRow } from './types'
type Bucket = 'day' | 'week' | 'month'
const BUCKET_SQL: Record<Bucket, { trunc: SQL, interval: SQL }> = {
day: { trunc: sql.raw("'day'"), interval: sql.raw("'1 day'::interval") },
week: { trunc: sql.raw("'week'"), interval: sql.raw("'1 week'::interval") },
month: { trunc: sql.raw("'month'"), interval: sql.raw("'1 month'::interval") },
}
function bucketLiterals(bucket: Bucket): { trunc: SQL, interval: SQL } {
const lit = BUCKET_SQL[bucket]
if (!lit) throw new Error(`Invalid bucket: ${String(bucket)}`)
return lit
}
const DRILL_DOWN_LIMIT = 200
const DRILL_DOWN_TASK_FIELDS = sql`
t.id::int as "id",
t.description as "description",
t.goal_id::int as "goalId",
g.name as "goalName",
coalesce(t.complete, false) as "complete",
t.priority_id::int as "priorityId",
t.end_date::text as "endDate",
t.date_creation::text as "date_creation",
t.date_complete::text as "date_complete"
`
export class AnalyticsRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
// ================== Goal lookups ==================
async fetchAllGoalIdsInOrg(organizationId: number): Promise<number[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: GoalsSchema.id })
.from(GoalsSchema)
.where(and(
eq(GoalsSchema.organizationId, organizationId),
eq(GoalsSchema.archive, 0),
)),
)
return (result ?? []).map(r => r.id).filter((id): id is number => id !== null)
}
async fetchGoalIdsWithPermissions(
userId: number,
email: string,
organizationId: number,
permissionNames: string[],
): Promise<number[]> {
if (permissionNames.length === 0) return []
const result = await this.db.dbDrizzle.execute<{ id: number }>(sql`
select g.id from tasks.goals g
where g.organization_id = ${organizationId}
and g.archive = 0
and (
g.owner = ${userId}
or (
select count(distinct p.name)
from collaboration.users cu
join collaboration.users_to_goals utg on utg.user_id = cu.id and utg.goal_id = g.id
join collaboration.users_to_roles utr on utr.user_id = cu.id
join collaboration.roles r on r.id = utr.role_id and r.goal_id = g.id
join collaboration.permissions_to_role ptr on ptr.role_id = r.id
join tv_auth.permissions p on p.id = ptr.permission_id
where cu.email = ${email}
and p.name = any(${sql`ARRAY[${sql.join(permissionNames.map(n => sql`${n}`), sql`, `)}]::text[]`})
) = ${permissionNames.length}
)
`)
return result.rows.map(r => Number(r.id)).filter(id => Number.isInteger(id))
}
async getGoalsForIds(ids: number[]) {
if (ids.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
id: GoalsSchema.id,
name: GoalsSchema.name,
})
.from(GoalsSchema)
.where(inArray(GoalsSchema.id, ids)),
)
return (result ?? []).map(r => ({ id: r.id ?? 0, name: r.name ?? '' }))
}
// ================== KPI ==================
async countCreated(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<CreatedTasksKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.count ?? 0)
}
async countCompleted(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<CompletedTasksKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.count ?? 0)
}
async countOverdue(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<OverdueKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and end_date is not null
and end_date < current_date
`)
return Number(result.rows[0]?.count ?? 0)
}
async medianCycleTime(goalIds: number[], range: AnalyticsRange): Promise<number | null> {
const result = await this.db.dbDrizzle.execute<CycleTimeKpiRow>(sql`
select percentile_cont(0.5) within group (
order by extract(epoch from (date_complete - date_creation)) / 86400
)::float as median
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and date_creation is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
const m = result.rows[0]?.median
return m === null || m === undefined ? null : Number(m)
}
async sumIncome(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<TotalIncomeKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type = 1
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumExpense(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<TotalExpenseKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type = 0
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumIncomeAndExpense(goalIds: number[], range: AnalyticsRange): Promise<{ income: number, expense: number }> {
const result = await this.db.dbDrizzle.execute<NetProfitKpiRow>(sql`
select
coalesce(sum(case when transaction_type = 1 then amount else 0 end), 0)::float as income,
coalesce(sum(case when transaction_type = 0 then amount else 0 end), 0)::float as expense
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type in (0, 1)
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
const r = result.rows[0] ?? { income: 0, expense: 0 }
return { income: Number(r.income), expense: Number(r.expense) }
}
async sumPlannedIncome(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<PlannedIncomeKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and transaction_type = 1
and amount is not null
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumPlannedExpense(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<PlannedExpenseKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and transaction_type = 0
and amount is not null
`)
return Number(result.rows[0]?.total ?? 0)
}
async amountCoverage(goalIds: number[]): Promise<{ total: number, withAmount: number }> {
const result = await this.db.dbDrizzle.execute<AmountCoverageKpiRow>(sql`
select
count(*)::int as total,
count(*) filter (where amount is not null)::int as with_amount
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
`)
const r = result.rows[0] ?? { total: 0, with_amount: 0 }
return { total: Number(r.total), withAmount: Number(r.with_amount) }
}
// ================== Productivity ==================
async fetchThroughput(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<ThroughputSectionRow[]> {
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
const goalIdsSql = toIntArraySql(goalIds)
const result = await this.db.dbDrizzle.execute<ThroughputSectionRow>(sql`
with buckets as (
select generate_series(
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
${intervalSql}
) as bucket
),
created as (
select date_trunc(${bucketSql}, date_creation) as bucket, count(*)::int as count
from tasks.tasks
where goal_id = any(${goalIdsSql})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
group by date_trunc(${bucketSql}, date_creation)
),
completed as (
select date_trunc(${bucketSql}, date_complete) as bucket, count(*)::int as count
from tasks.tasks
where goal_id = any(${goalIdsSql})
and complete = true
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
group by date_trunc(${bucketSql}, date_complete)
)
select
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
coalesce(c.count, 0)::int as created,
coalesce(d.count, 0)::int as completed
from buckets b
left join created c on c.bucket = b.bucket
left join completed d on d.bucket = b.bucket
order by b.bucket asc
`)
return result.rows as ThroughputSectionRow[]
}
async fetchPriorityMix(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<PriorityMixOverTimeSectionRow[]> {
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
const goalIdsSql = toIntArraySql(goalIds)
const result = await this.db.dbDrizzle.execute<PriorityMixOverTimeSectionRow>(sql`
with buckets as (
select generate_series(
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
${intervalSql}
) as bucket
),
created as (
select date_trunc(${bucketSql}, date_creation) as bucket, priority_id
from tasks.tasks
where goal_id = any(${goalIdsSql})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
)
select
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
coalesce(sum(case when c.priority_id = 3 then 1 else 0 end), 0)::int as high,
coalesce(sum(case when c.priority_id = 2 then 1 else 0 end), 0)::int as medium,
coalesce(sum(case when c.priority_id = 1 then 1 else 0 end), 0)::int as low,
coalesce(sum(case when c.priority_id is null then 1 else 0 end), 0)::int as none
from buckets b
left join created c on c.bucket = b.bucket
group by b.bucket
order by b.bucket asc
`)
return result.rows as PriorityMixOverTimeSectionRow[]
}
// ================== Workload ==================
async fetchWorkloadByAssignee(goalIds: number[]): Promise<WorkloadByAssigneeSectionRow[]> {
const result = await this.db.dbDrizzle.execute<WorkloadByAssigneeSectionRow>(sql`
select * from (
select
cu.id as user_id,
coalesce(cu.email, 'Unknown') as user_name,
sum(case when t.priority_id = 3 then 1 else 0 end)::int as high,
sum(case when t.priority_id = 2 then 1 else 0 end)::int as medium,
sum(case when t.priority_id = 1 then 1 else 0 end)::int as low,
sum(case when t.priority_id is null then 1 else 0 end)::int as no_priority
from tasks.tasks t
join tasks_auth.task_assignee ta on ta.task_id = t.id
join collaboration.users cu on cu.id = ta.collab_user_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
group by cu.id, cu.email
) s
order by (s.high * 3 + s.medium * 2 + s.low + s.no_priority) desc
limit 30
`)
return result.rows as WorkloadByAssigneeSectionRow[]
}
async fetchBlockedByDeps(goalIds: number[]): Promise<BlockedByDependenciesSectionRow[]> {
const result = await this.db.dbDrizzle.execute<BlockedByDependenciesSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
count(distinct t.id)::int as blocked
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
join tasks.task_relations r on r.to_task_id = t.id
join tasks.tasks src on src.id = r.from_task_id
where g.id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and (src.complete is null or src.complete = false)
group by g.id, g.name
order by blocked desc
`)
return result.rows as BlockedByDependenciesSectionRow[]
}
async fetchTimeInKanbanStatus(goalId: number, accessibleGoalIds: number[]): Promise<TimeInKanbanStatusSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TimeInKanbanStatusSectionRow>(sql`
select
s.id as status_id,
coalesce(s.name, 'Без статуса') as status_name,
avg(extract(epoch from (now() - coalesce(t.edit_date, t.date_creation))) / 86400.0)::float as avg_days,
count(t.id)::int as task_count
from tasks.tasks t
left join tasks.statuses s on s.id = t.status_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
group by s.id, s.name, s.view_order
order by s.view_order nulls last, s.name
`)
return result.rows as TimeInKanbanStatusSectionRow[]
}
async fetchAgingOpenTasks(goalIds: number[]): Promise<AgingOpenTasksSectionRow[]> {
const result = await this.db.dbDrizzle.execute<AgingOpenTasksSectionRow>(sql`
select
cu.id as user_id,
coalesce(cu.email, 'Unknown') as user_name,
avg(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as avg_age,
max(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as max_age,
count(distinct t.id)::int as task_count
from tasks.tasks t
join tasks_auth.task_assignee ta on ta.task_id = t.id
join collaboration.users cu on cu.id = ta.collab_user_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
group by cu.id, cu.email
having count(distinct t.id) > 0
order by avg_age desc nulls last
limit 20
`)
return result.rows as AgingOpenTasksSectionRow[]
}
// ================== Quality ==================
async fetchOverdueByAge(goalIds: number[]): Promise<OverdueByAgeSectionRow> {
const result = await this.db.dbDrizzle.execute<OverdueByAgeSectionRow>(sql`
select
sum(case when (current_date - end_date) between 1 and 3 then 1 else 0 end)::int as bucket_1_3,
sum(case when (current_date - end_date) between 4 and 7 then 1 else 0 end)::int as bucket_4_7,
sum(case when (current_date - end_date) between 8 and 14 then 1 else 0 end)::int as bucket_8_14,
sum(case when (current_date - end_date) > 14 then 1 else 0 end)::int as bucket_15_plus
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and end_date is not null
and end_date < current_date
`)
return (result.rows[0] ?? {
bucket_1_3: 0, bucket_4_7: 0, bucket_8_14: 0, bucket_15_plus: 0,
}) as OverdueByAgeSectionRow
}
async fetchCycleTimeHistogram(goalIds: number[], range: AnalyticsRange): Promise<CycleTimeHistogramSectionRow> {
const result = await this.db.dbDrizzle.execute<CycleTimeHistogramSectionRow>(sql`
with durations as (
select extract(epoch from (date_complete - date_creation)) / 86400.0 as days
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and date_creation is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
)
select
sum(case when days < 1 then 1 else 0 end)::int as bucket_0_1,
sum(case when days >= 1 and days < 3 then 1 else 0 end)::int as bucket_1_3,
sum(case when days >= 3 and days < 7 then 1 else 0 end)::int as bucket_3_7,
sum(case when days >= 7 and days < 14 then 1 else 0 end)::int as bucket_7_14,
sum(case when days >= 14 and days < 30 then 1 else 0 end)::int as bucket_14_30,
sum(case when days >= 30 then 1 else 0 end)::int as bucket_30_plus
from durations
`)
return (result.rows[0] ?? {
bucket_0_1: 0, bucket_1_3: 0, bucket_3_7: 0,
bucket_7_14: 0, bucket_14_30: 0, bucket_30_plus: 0,
}) as CycleTimeHistogramSectionRow
}
async fetchStaleTasks(goalIds: number[]): Promise<StaleTasksSectionRow[]> {
const result = await this.db.dbDrizzle.execute<StaleTasksSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
count(t.id)::int as stale
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
group by g.id, g.name
order by stale desc
`)
return result.rows as StaleTasksSectionRow[]
}
async fetchCycleTimePerProject(goalIds: number[], range: AnalyticsRange): Promise<CycleTimePerProjectSectionRow[]> {
const result = await this.db.dbDrizzle.execute<CycleTimePerProjectSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
percentile_cont(0.5) within group (
order by extract(epoch from (t.date_complete - t.date_creation)) / 86400.0
)::float as median_days,
count(t.id)::int as completed
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.complete = true
and t.date_complete is not null
and t.date_creation is not null
and t.date_complete >= ${range.from.toISOString()}
and t.date_complete < ${range.to.toISOString()}
group by g.id, g.name
having count(t.id) > 0
order by median_days desc nulls last
`)
return result.rows as CycleTimePerProjectSectionRow[]
}
// ================== Usage ==================
async fetchStatusDistribution(goalId: number, accessibleGoalIds: number[]): Promise<StatusDistributionSectionRow[]> {
const result = await this.db.dbDrizzle.execute<StatusDistributionSectionRow>(sql`
select
s.id as status_id,
coalesce(s.name, 'No status') as status_name,
count(t.id)::int as count
from tasks.tasks t
left join tasks.statuses s on s.id = t.status_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
group by s.id, s.name
having count(t.id) > 0
order by count desc
`)
return result.rows as StatusDistributionSectionRow[]
}
async fetchActiveProjects(goalIds: number[]): Promise<ActiveProjectsSectionRow[]> {
const result = await this.db.dbDrizzle.execute<ActiveProjectsSectionRow>(sql`
with last_activity as (
select g.id as goal_id,
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
from tasks.goals g
left join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
group by g.id
)
select status_key, count(*)::int as count
from (
select
case
when last_at is null then 'empty'
when last_at >= now() - interval '14 days' then 'active'
when last_at >= now() - interval '30 days' then 'fading'
else 'dead'
end as status_key
from last_activity
) s
group by status_key
order by case status_key
when 'active' then 1
when 'fading' then 2
when 'dead' then 3
when 'empty' then 4
end
`)
return result.rows as ActiveProjectsSectionRow[]
}
// ================== Financial ==================
async fetchIncomeExpenseMonth(goalIds: number[], range: AnalyticsRange): Promise<IncomeExpenseMonthSectionRow[]> {
const result = await this.db.dbDrizzle.execute<IncomeExpenseMonthSectionRow>(sql`
with months as (
select generate_series(
date_trunc('month', ${range.from.toISOString()}::timestamp),
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
'1 month'::interval
) as month
),
totals as (
select
date_trunc('month', date_complete) as month,
sum(case when transaction_type = 1 then coalesce(amount, 0) else 0 end)::float as income,
sum(case when transaction_type = 0 then coalesce(amount, 0) else 0 end)::float as expense
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and amount is not null
and transaction_type in (0, 1)
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
group by date_trunc('month', date_complete)
)
select
to_char(m.month, 'YYYY-MM') as month,
coalesce(t.income, 0)::float as income,
coalesce(t.expense, 0)::float as expense
from months m
left join totals t on t.month = m.month
order by m.month asc
`)
return result.rows as IncomeExpenseMonthSectionRow[]
}
async fetchIncomeExpensePerProject(goalIds: number[]): Promise<IncomeExpensePerProjectSectionRow[]> {
const result = await this.db.dbDrizzle.execute<IncomeExpensePerProjectSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense,
(sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)
- sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end))::float as net
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.amount is not null
and t.transaction_type in (0, 1)
group by g.id, g.name
having sum(coalesce(t.amount, 0)) > 0
order by net desc
limit 20
`)
return result.rows as IncomeExpensePerProjectSectionRow[]
}
async fetchTopProjectsByAmount(goalIds: number[]): Promise<TopProjectsByAmountSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TopProjectsByAmountSectionRow>(sql`
select * from (
select
g.id as goal_id,
g.name as goal_name,
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.amount is not null
and t.transaction_type in (0, 1)
group by g.id, g.name
having sum(coalesce(t.amount, 0)) > 0
) s
order by (s.income + s.expense) desc
limit 15
`)
return result.rows as TopProjectsByAmountSectionRow[]
}
// ================== Drill-down ==================
async fetchOverdueTasks(goalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.end_date is not null
and t.end_date < current_date
order by t.end_date asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOverdueTasksInRange(
goalIds: number[],
minDays: number,
maxDays: number | null,
): Promise<DrillDownTaskRow[]> {
const maxClause = maxDays !== null
? sql`and (current_date - t.end_date) <= ${maxDays}`
: sql``
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.end_date is not null
and (current_date - t.end_date) >= ${minDays}
${maxClause}
order by t.end_date asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchStaleTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
order by coalesce(t.edit_date, t.date_creation) asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchBlockedTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks.task_relations r on r.to_task_id = t.id
join tasks.tasks src on src.id = r.from_task_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
and (src.complete is null or src.complete = false)
order by t.id
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksAssignedTo(goalIds: number[], userId: number): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks_auth.task_assignee ta on ta.task_id = t.id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and ta.collab_user_id = ${userId}
order by t.id, t.date_creation asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksAssignedWithPriority(
goalIds: number[],
userId: number,
priorityFilter: number | 'null' | undefined,
): Promise<DrillDownTaskRow[]> {
const priorityClause: SQL = priorityFilter === undefined
? sql``
: priorityFilter === 'null'
? sql`and t.priority_id is null`
: sql`and t.priority_id = ${priorityFilter}`
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks_auth.task_assignee ta on ta.task_id = t.id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and ta.collab_user_id = ${userId}
${priorityClause}
order by t.id, t.date_creation desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksInActiveProjects(
goalIds: number[],
statusKey: 'active' | 'fading' | 'dead',
): Promise<DrillDownTaskRow[]> {
let activityClause: SQL
if (statusKey === 'active') {
activityClause = sql`last_at >= now() - interval '14 days'`
} else if (statusKey === 'fading') {
activityClause = sql`last_at >= now() - interval '30 days' and last_at < now() - interval '14 days'`
} else {
activityClause = sql`last_at < now() - interval '30 days'`
}
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
with last_activity as (
select g.id as goal_id,
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
from tasks.goals g
left join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
group by g.id
),
matching_goals as (
select goal_id from last_activity where ${activityClause}
)
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join matching_goals m on m.goal_id = t.goal_id
where (t.complete is null or t.complete = false)
order by t.id, coalesce(t.edit_date, t.date_creation) desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchPlannedTasksByType(goalIds: number[], transactionType: 0 | 1): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.transaction_type = ${transactionType}
and t.amount is not null
order by t.amount desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
}
@@ -0,0 +1,27 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { AnalyticsController } from './AnalyticsController'
import { CanAccessAnalytics } from './middlewares/CanAccessAnalytics'
export default class AnalyticsRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: AnalyticsController
constructor() {
this.router = Router()
this.controller = new AnalyticsController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
const guards = [IsLoggedIn, RejectApiTokenAuth, CanAccessAnalytics]
this.router.get('/sections', guards, this.controller.fetchSections)
this.router.get('/drilldown/:sectionId', guards, this.controller.fetchDrillDown)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
const MAX_INT32 = 2147483647
export function toIntArraySql(ids: ReadonlyArray<number>): SQL {
const safe = ids.filter(
(id): id is number =>
typeof id === 'number' && Number.isInteger(id) && id > 0 && id < MAX_INT32,
)
return sql.raw(`ARRAY[${safe.join(',')}]::int[]`)
}
export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
if (!raw) return {}
try {
const result = DrillDownMetaArkType(JSON.parse(raw))
return result instanceof type.errors ? {} : result
} catch {
return {}
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
if (period === 'custom') {
if (!from || !to) return null
const fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
}
@@ -0,0 +1,41 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
import { parsePositiveInt } from '../../../utils/helpers'
export const CanAccessAnalytics = async (req: Request, res: Response, next: NextFunction) => {
const orgId = parsePositiveInt(req.query?.organizationId)
if (orgId === null) return res.status(400).end()
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
if (!member) return res.status(403).end()
if (await req.appUser.organizationManager.isCurrentUserOrgOwner(orgId)) return next()
const scope = req.query?.scope
if (scope === 'project') {
const goalId = parsePositiveInt(req.query?.goalId)
if (goalId === null) return res.status(400).end()
const checker = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for CanAccessAnalytics middleware')
return res.status(500).end()
}
if (!checker.hasPermissions(GoalPermissions.ANALYTICS_CAN_VIEW)) return res.status(403).end()
return next()
}
const accessibleGoalIds = await req.appUser.analyticsManager
.getAccessibleGoalIds(orgId)
.catch(logError)
if (!accessibleGoalIds || accessibleGoalIds.length === 0) return res.status(403).end()
return next()
}
@@ -0,0 +1,81 @@
import type { SectionBuilder } from '../types'
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
import { OverdueKpi } from './kpi/OverdueKpi'
import { CycleTimeKpi } from './kpi/CycleTimeKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
// import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
// import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
import { NetProfitKpi } from './financial/NetProfitKpi'
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
const builders: SectionBuilder[] = [
// KPI
new CreatedTasksKpi(),
new CompletedTasksKpi(),
new OverdueKpi(),
new CycleTimeKpi(),
new TotalIncomeKpi(),
new TotalExpenseKpi(),
new NetProfitKpi(),
new PlannedIncomeKpi(),
new PlannedExpenseKpi(),
new AmountCoverageKpi(),
// Productivity
new ThroughputSection(),
// new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
// new BlockedByDependenciesSection(),
// new TimeInKanbanStatusSection(),
// new AgingOpenTasksSection(),
// Quality
new OverdueByAgeSection(),
// new CycleTimeHistogramSection(),
new StaleTasksSection(),
// new CycleTimePerProjectSection(),
// Usage
// new StatusDistributionSection(),
new ActiveProjectsSection(),
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new TopProjectsByAmountSection(),
]
export class SectionRegistry {
private readonly byId: Map<string, SectionBuilder>
constructor() {
this.byId = new Map(builders.map(b => [b.id, b]))
}
all(): SectionBuilder[] {
return [...this.byId.values()]
}
get(id: string): SectionBuilder | undefined {
return this.byId.get(id)
}
filterByIds(ids?: string[]): SectionBuilder[] {
if (!ids || ids.length === 0) return this.all()
return ids.map(id => this.byId.get(id)).filter((b): b is SectionBuilder => !!b)
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class AmountCoverageKpi implements SectionBuilder {
readonly id = 'kpi.amount_coverage'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { total, withAmount } = await ctx.repository.amountCoverage(ctx.accessibleGoalIds)
const percent = total === 0 ? 0 : Math.round((withAmount / total) * 100)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: percent,
unit: 'percent',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'percent' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,68 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class IncomeExpenseMonthSection implements SectionBuilder {
readonly id = 'chart.income_expense_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['bar', 'line', 'area', 'stackedArea'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchIncomeExpenseMonth(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.month),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
},
],
unit: 'currency',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class IncomeExpensePerProjectSection implements SectionBuilder {
readonly id = 'chart.income_expense_per_project'
readonly group = 'financial' as const
readonly allowedChartTypes = ['bar', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchIncomeExpensePerProject(ctx.accessibleGoalIds)
const loc = this.loc
const goalIds = rows.map(r => r.goal_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
meta: { goalIds },
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
meta: { goalIds },
},
],
unit: 'currency',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,78 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class NetProfitKpi implements SectionBuilder {
readonly id = 'kpi.net_profit'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const cur = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const current = cur.income - cur.expense
const previous = prev.income - prev.expense
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, previous),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return {
value: 100,
direction: current > 0 ? ('up' as const) : ('down' as const),
isGood: current >= 0,
}
}
const pct = Math.round(((current - prev) / Math.abs(prev)) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,57 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
import { sectionLocales } from '../locales'
export class PlannedExpenseKpi implements SectionBuilder {
readonly id = 'kpi.planned_expense'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.sumPlannedExpense(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(value),
unit: 'currency',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 0)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,57 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
import { sectionLocales } from '../locales'
export class PlannedIncomeKpi implements SectionBuilder {
readonly id = 'kpi.planned_income'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.sumPlannedIncome(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(value),
unit: 'currency',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 1)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,73 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TopProjectsByAmountSection implements SectionBuilder {
readonly id = 'chart.top_projects_by_amount'
readonly group = 'financial' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchTopProjectsByAmount(ctx.accessibleGoalIds)
const loc = this.loc
const goalIds = rows.map(r => r.goal_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
stack: 'amount',
meta: { goalIds },
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
stack: 'amount',
meta: { goalIds },
},
],
unit: 'currency',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TotalExpenseKpi implements SectionBuilder {
readonly id = 'kpi.total_expense'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: false }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct <= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TotalIncomeKpi implements SectionBuilder {
readonly id = 'kpi.total_income'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CompletedTasksKpi implements SectionBuilder {
readonly id = 'kpi.completed_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CreatedTasksKpi implements SectionBuilder {
readonly id = 'kpi.created_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeKpi implements SectionBuilder {
readonly id = 'kpi.cycle_time'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const median = await ctx.repository.medianCycleTime(ctx.accessibleGoalIds, ctx.range)
const value = median === null ? 0 : Math.round(median * 10) / 10
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'days',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,56 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class OverdueKpi implements SectionBuilder {
readonly id = 'kpi.overdue'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.countOverdue(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'count',
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchOverdueTasks(ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,682 @@
import type { LocalizedText } from 'taskview-api'
export type SectionLocale = {
title: LocalizedText
description?: LocalizedText
help?: {
summary: LocalizedText
details: LocalizedText
}
datasets?: Record<string, LocalizedText>
labels?: Record<string, LocalizedText>
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
}
const join = (parts: string[]) => parts.join('\n')
export const sectionLocales = {
// ===== KPI =====
'kpi.created_tasks': {
title: { ru: 'Создано задач', en: 'Created tasks' },
help: {
summary: {
ru: 'Количество созданных задач за выбранный период',
en: 'Number of tasks created in the selected period',
},
details: {
ru: 'Показывает входящий поток работы. Сравните со счётчиком «Закрыто задач» — если создаётся больше, чем закрывается, команда не успевает справляться, и бэклог растёт. Delta показывает изменение относительно предыдущего периода той же длины.',
en: 'Shows incoming work volume. Compare against "Completed tasks" — if creation outpaces completion, the backlog is growing and the team is falling behind. The delta compares against the equivalent previous period.',
},
},
},
'kpi.completed_tasks': {
title: { ru: 'Закрыто задач', en: 'Completed tasks' },
help: {
summary: {
ru: 'Количество задач, закрытых за период',
en: 'Tasks completed in the selected period',
},
details: {
ru: 'Реальный выход команды — сколько работы было доведено до конца. Сопоставляйте с «Создано задач»: если создано ≫ закрыто, нарастает бэклог. Стабильный тренд роста = хороший признак ускорения процессов.',
en: 'Actual team output — how much work got finished. Compare with "Created tasks": if creation far exceeds completion, the backlog is growing. A steady upward trend indicates process acceleration.',
},
},
},
'kpi.overdue': {
title: { ru: 'Просрочено', en: 'Overdue' },
help: {
summary: {
ru: 'Открытые задачи с прошедшим дедлайном',
en: 'Open tasks past their due date',
},
details: {
ru: 'Работа, требующая срочного внимания. Если число стабильно растёт — команда перегружена или дедлайны нереалистичны. Кликните по KPI, чтобы увидеть конкретные просроченные задачи.',
en: 'Work that needs immediate attention. A steadily growing number signals team overload or unrealistic deadlines. Click the KPI to drill into the specific overdue tasks.',
},
},
},
'kpi.cycle_time': {
title: { ru: 'Cycle time (медиана)', en: 'Cycle time (median)' },
description: { ru: 'Медианное время от создания до завершения', en: 'Median time from creation to completion' },
help: {
summary: {
ru: 'Медианное время от создания задачи до её закрытия',
en: 'Median time from task creation to completion',
},
details: {
ru: 'Сколько в среднем живёт задача с момента создания до закрытия. Рост показателя = замедление процессов. Используется медиана, а не среднее — выбросы (застрявшие задачи) не искажают картину.',
en: "How long a task typically lives from creation to completion. A rising number means slowing processes. We use the median rather than the mean so that outliers (stuck tasks) don't distort the picture.",
},
},
},
'kpi.total_income': {
title: { ru: 'Доходы', en: 'Income' },
description: { ru: 'Сумма за период', en: 'Sum for period' },
help: {
summary: {
ru: 'Сумма всех доходов за выбранный период',
en: 'Total income for the selected period',
},
details: {
ru: 'Складываются суммы по закрытым задачам, у которых отмечен тип «доход». Сравнение — с предыдущим периодом такой же длины. Зелёная стрелка вверх — доход вырос, это хорошо.',
en: 'Sum of amounts on closed tasks marked as "income". Compared against the previous equivalent period. A green up arrow means income grew, which is good.',
},
},
},
'kpi.total_expense': {
title: { ru: 'Расходы', en: 'Expense' },
description: { ru: 'Сумма за период', en: 'Sum for period' },
help: {
summary: {
ru: 'Сумма всех расходов за выбранный период',
en: 'Total expenses for the selected period',
},
details: {
ru: 'Складываются суммы по закрытым задачам, у которых отмечен тип «расход». Сравнение — с предыдущим периодом такой же длины. Зелёная стрелка вниз — расходы снизились, это хорошо.',
en: 'Sum of amounts on closed tasks marked as "expense". Compared against the previous equivalent period. A green down arrow means expenses dropped, which is good.',
},
},
},
'kpi.planned_income': {
title: { ru: 'Планируемые доходы', en: 'Planned income' },
description: { ru: 'По открытым задачам', en: 'From open tasks' },
help: {
summary: {
ru: 'Сумма доходов по открытым задачам с указанной суммой',
en: 'Sum of income from open tasks with amount set',
},
details: {
ru: 'Складываются суммы по всем открытым (не завершённым) задачам с типом «доход» и заполненной суммой. Это снимок ожидаемых поступлений — пока задача не закрыта, доход считается планируемым. Не зависит от выбранного периода.',
en: 'Sum of amounts from all open (incomplete) tasks marked as "income" with a filled amount. This is a snapshot of expected income — until a task is closed, the income is planned. Not affected by the selected period.',
},
},
},
'kpi.planned_expense': {
title: { ru: 'Планируемые расходы', en: 'Planned expense' },
description: { ru: 'По открытым задачам', en: 'From open tasks' },
help: {
summary: {
ru: 'Сумма расходов по открытым задачам с указанной суммой',
en: 'Sum of expenses from open tasks with amount set',
},
details: {
ru: 'Складываются суммы по всем открытым (не завершённым) задачам с типом «расход» и заполненной суммой. Это снимок ожидаемых трат — пока задача не закрыта, расход считается планируемым. Не зависит от выбранного периода.',
en: 'Sum of amounts from all open (incomplete) tasks marked as "expense" with a filled amount. This is a snapshot of expected spending — until a task is closed, the expense is planned. Not affected by the selected period.',
},
},
},
'kpi.net_profit': {
title: { ru: 'Чистая прибыль', en: 'Net profit' },
description: { ru: 'Доходы минус расходы', en: 'Income minus expense' },
help: {
summary: {
ru: 'Чистая прибыль за период: доходы минус расходы',
en: 'Net profit for the period: income minus expense',
},
details: {
ru: 'Считается как разница между всеми доходами и расходами по закрытым задачам в периоде. Положительное число — заработали больше, чем потратили. Дельта показывает, насколько изменилась прибыль по сравнению с предыдущим периодом такой же длины.',
en: 'Calculated as the difference between all income and expense from closed tasks in the period. A positive number means you earned more than you spent. The delta shows how profit changed vs the previous equivalent period.',
},
},
},
'kpi.amount_coverage': {
title: { ru: 'Заполнено amount', en: 'Amount coverage' },
description: { ru: '% задач с заполненной суммой', en: '% of tasks with amount filled' },
help: {
summary: {
ru: 'Доля задач с заполненным полем суммы',
en: 'Share of tasks with the amount field filled',
},
details: {
ru: 'Показатель качества данных для финансовой аналитики. Если % низкий — графики «Доходы/расходы» и «Топ проектов» отражают только малую часть реальности. Стоит либо не доверять им, либо наладить практику заполнения amount/transactionType.',
en: 'Data quality indicator for financial analytics. If this percentage is low, the Income/Expense and Top Projects charts only reflect a small slice of reality — either treat them with caution or establish a practice of filling the amount/transactionType fields.',
},
},
},
// ===== Productivity =====
'chart.throughput': {
title: { ru: 'Создано vs закрыто', en: 'Created vs completed' },
description: {
ru: 'Баланс входящей и закрываемой работы',
en: 'Balance of incoming and completed work',
},
help: {
summary: {
ru: 'Созданные и закрытые задачи по периодам',
en: 'Created and completed tasks over time',
},
details: {
ru: 'Главный индикатор здоровья проекта. Зазор между линиями — предупреждение: вы создаёте больше, чем закрываете, и бэклог растёт. Постоянный зазор → накопление долга, команда не справляется. Линии близко друг к другу → работа идёт в темпе поступления.',
en: 'The primary project health indicator. A gap between the lines is a warning — creation outpaces completion, so the backlog is growing. A persistent gap means the team is falling behind; tight lines mean work is getting done at the rate it arrives.',
},
},
datasets: {
created: { ru: 'Создано', en: 'Created' },
completed: { ru: 'Закрыто', en: 'Completed' },
},
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.priority_mix': {
title: { ru: 'Приоритеты создаваемых задач', en: 'Priority mix over time' },
description: { ru: 'Распределение новых задач по приоритету', en: 'Distribution of new tasks by priority' },
help: {
summary: {
ru: 'Как меняется распределение приоритетов у создаваемых задач',
en: 'How priority mix of newly created tasks shifts over time',
},
details: {
ru: 'Если доля High растёт — вероятно, проблемы с планированием или команда в режиме пожаротушения. Здоровая продуктовая работа имеет больше Medium и Low, чем High. Также обратите внимание на долю задач «Без приоритета» — это сигнал плохой практики триажа.',
en: 'If the High share grows, the team may be firefighting or planning poorly. Healthy product work has more Medium/Low than High. Also watch the "No priority" share — a large portion points to poor triage practice.',
},
},
datasets: {
high: { ru: 'Высокий', en: 'High' },
medium: { ru: 'Средний', en: 'Medium' },
low: { ru: 'Низкий', en: 'Low' },
none: { ru: 'Без приоритета', en: 'No priority' },
},
yAxisLabel: { ru: 'Создано задач', en: 'Tasks created' },
},
// ===== Workload =====
'chart.workload_by_assignee': {
title: { ru: 'Нагрузка по исполнителям', en: 'Workload by assignee' },
description: { ru: 'Открытые задачи, сгруппированные по приоритету', en: 'Open tasks grouped by priority' },
help: {
summary: {
ru: 'Количество открытых задач на каждого исполнителя с разбивкой по приоритету',
en: 'Open tasks per assignee, broken down by priority',
},
details: {
ru: 'Быстрый взгляд на перегруз команды. Если у одного человека 15+ задач или много High-приоритета — нужно перераспределить нагрузку. Это не рейтинг эффективности: размер задач разный, и некоторые люди формально числятся в assignee, но не работают.',
en: 'A quick check for team overload. If one person has 15+ tasks or many High-priority items, workload needs rebalancing. This is not a performance ranking: task sizes vary and some people appear as formal assignees without actively working.',
},
},
datasets: {
high: { ru: 'Высокий', en: 'High' },
medium: { ru: 'Средний', en: 'Medium' },
low: { ru: 'Низкий', en: 'Low' },
no_priority: { ru: 'Без приоритета', en: 'No priority' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.blocked_by_deps': {
title: { ru: 'Заблокировано зависимостями', en: 'Blocked by dependencies' },
description: { ru: 'Открытые задачи, ждущие завершения зависимостей', en: 'Open tasks waiting on incomplete prerequisites' },
help: {
summary: {
ru: 'Открытые задачи, которые не могут стартовать, пока не закрыты их предшественники',
en: 'Open tasks that cannot start until their predecessors are completed',
},
details: {
ru: 'Явные блокеры процесса — здесь нужно вмешательство PM в первую очередь. Высокое число = поток остановлен, нужно разблокировать ключевые задачи. Зависимости берутся из графа задач (стрелка от A к B = B зависит от A).',
en: 'Explicit process blockers — the PM should address these first. A high count means the pipeline is stalled and key prerequisites need attention. Dependencies are derived from the task graph (an arrow from A to B means B depends on A).',
},
},
datasets: {
blocked: { ru: 'Заблокировано', en: 'Blocked' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.time_in_kanban_status': {
title: { ru: 'Время в колонках канбана', en: 'Time in kanban columns' },
description: { ru: 'Среднее время жизни открытой задачи в каждом статусе', en: 'Average open-task age per kanban column' },
help: {
summary: {
ru: 'Среднее время, которое открытые задачи проводят в каждом статусе',
en: 'Average age of open tasks broken down by kanban column',
},
details: {
ru: 'Выявляет узкие места процесса. Если задачи застревают в «Review» на 5+ дней — не хватает ревьюеров. Если в «In Progress» — WIP-лимит превышен. Требует выбора проекта, потому что колонки канбана уникальны для каждого.',
en: 'Reveals process bottlenecks. If tasks sit in "Review" for 5+ days, you lack reviewers; long times in "In Progress" mean the WIP limit is exceeded. Requires selecting a project because kanban columns are unique per project.',
},
},
datasets: {
avg_days: { ru: 'Среднее время в колонке', en: 'Avg time in column' },
},
yAxisLabel: { ru: 'Дней', en: 'Days' },
},
'chart.aging_open_tasks': {
title: { ru: 'Возраст открытых задач', en: 'Aging of open tasks' },
description: { ru: 'Средний и максимальный возраст открытых задач по исполнителям', en: 'Average and maximum open-task age per assignee' },
help: {
summary: {
ru: 'Сколько дней прошло с момента создания открытых задач, по исполнителям',
en: "Days since creation for each assignee's open tasks",
},
details: {
ru: join([
'Что считается «возрастом»:',
'Сколько дней прошло с момента создания задачи до сегодня. Например: задача создана 1 апреля, сегодня 21 апреля → возраст = 20 дней.',
'',
'Что показывает график:',
'Для каждого исполнителя берутся все его открытые (незавершённые) задачи и считается:',
'• Средний возраст — насколько старые в среднем его задачи',
'• Максимум — возраст самой старой его открытой задачи',
'',
'Как читать:',
'• Высокий средний → накопилось много старых задач: возможно, перегруз или человек не двигает бэклог',
'• Высокий максимум при низком среднем → в целом всё быстро, но есть одна-две «висящих» задачи — добить или закрыть как устаревшие',
'• Оба значения низкие → задачи либо свежие, либо быстро закрываются — здоровая ситуация',
'• Большой разрыв между ними → есть «тяжёлые хвосты»: отдельные давние задачи выбиваются из общего ритма',
'',
'Нюансы:',
'• Учитываются исполнители (assignee), а не создатели задач',
'• Если задача назначена двоим — попадёт к обоим (это корректно: оба за неё отвечают)',
'• Сверху списка — исполнители с самой старой в среднем работой',
'',
'Что НЕ измеряется:',
'• Время в работе — это отдельная метрика Cycle Time',
'• Время до дедлайна — смотрите Overdue-метрики',
'• Эффективность — старая задача может быть просто большой или заблокированной',
]),
en: join([
'What "age" means:',
'How many days have passed since the task was created until today. Example: task created Apr 1, today is Apr 21 → age = 20 days.',
'',
'What the chart shows:',
'For each assignee, we take all their open (incomplete) tasks and compute:',
'• Average age — how old their tasks are on average',
'• Max — age of their oldest open task',
'',
'How to read it:',
'• High average → lots of old tasks piled up: possibly overloaded or not moving the backlog',
'• High max with low average → generally fast, but one or two "stuck" tasks — finish them or close as obsolete',
'• Both low → tasks are either fresh or closed quickly — a healthy state',
'• Large gap between them → "heavy tails": isolated old tasks breaking away from the norm',
'',
'Notes:',
'• Counted by assignee, not by creator',
'• A task assigned to two people appears for both (correct: both are responsible)',
'• Top of the list = assignees with the oldest typical work',
'',
'What is NOT measured:',
"• Time in active work — that's the separate Cycle Time metric",
'• Time until deadline — see Overdue metrics',
'• Efficiency — an old task may simply be large or blocked',
]),
},
},
datasets: {
avg_age: { ru: 'Средний возраст', en: 'Average age' },
max_age: { ru: 'Максимум', en: 'Max' },
},
xAxisLabel: { ru: 'Дней', en: 'Days' },
},
// ===== Quality =====
'chart.overdue_by_age': {
title: { ru: 'Просроченные по срокам давности', en: 'Overdue by age' },
help: {
summary: {
ru: 'Просроченные задачи, сгруппированные по времени с даты дедлайна',
en: 'Overdue tasks grouped by how long they have been overdue',
},
details: {
ru: 'Приоритизация «тушения пожаров». Задачи, просроченные 1-3 дня — скорее всего в работе и скоро закроются. 15+ дней — либо срочно решать, либо закрывать как устаревшие: такие дедлайны уже потеряли смысл.',
en: 'Firefighting priority. Tasks overdue 13 days are likely close to finishing. Tasks overdue 15+ days need urgent resolution or should be closed as obsolete — those deadlines have already lost meaning.',
},
},
datasets: {
overdue: { ru: 'Просрочено', en: 'Overdue' },
},
labels: {
bucket_1_3: { ru: '13 дн', en: '13 d' },
bucket_4_7: { ru: '47 дн', en: '47 d' },
bucket_8_14: { ru: '814 дн', en: '814 d' },
bucket_15_plus: { ru: '15+ дн', en: '15+ d' },
},
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.cycle_time_histogram': {
title: { ru: 'Распределение cycle time', en: 'Cycle time distribution' },
description: {
ru: 'Сколько задач закрылось в каждом диапазоне по длительности',
en: 'How many tasks closed in each duration range',
},
help: {
summary: {
ru: 'Сколько закрытых задач попало в каждый диапазон по длительности',
en: 'How many completed tasks fall into each duration range',
},
details: {
ru: join([
'Что показывает:',
'Все закрытые за период задачи разбиты на 6 диапазонов по длительности (от создания до закрытия). Столбец показывает, сколько задач попало в каждый диапазон.',
'',
'Как читать:',
'• Большинство в «<1д» и «1-3д» → команда закрывает задачи быстро',
'• Перевес в «7-14д» и больше → задачи крупные или долго лежат в бэклоге',
'• Высокий столбец в «30+д» → есть проблема с давними задачами, которые наконец-то были закрыты',
'',
'Нюанс:',
'Учитывается полная жизнь задачи — от создания до закрытия, включая время в бэклоге. Задача, созданная 2 месяца назад и закрытая за день, попадёт в «30+д», а не в «<1д».',
]),
en: join([
'What it shows:',
'All tasks closed during the period are split into 6 duration buckets (from creation to completion). Each bar shows how many tasks fell into that bucket.',
'',
'How to read it:',
'• Most in "<1d" and "1-3d" → team closes tasks quickly',
'• Skewed toward "7-14d" and higher → tasks are large or sit in the backlog for a long time',
'• Tall bar in "30+d" → old tasks finally closed, signalling backlog debt',
'',
'Note:',
'Measures the full task life — from creation to close, including time spent in the backlog. A task created 2 months ago and finished in a day lands in "30+d", not "<1d".',
]),
},
},
datasets: {
tasks: { ru: 'Задач', en: 'Tasks' },
},
xAxisLabel: { ru: 'Длительность', en: 'Duration' },
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.stale_tasks': {
title: { ru: 'Забытые задачи', en: 'Stale tasks' },
description: { ru: 'Открытые задачи без изменений более 30 дней', en: 'Open tasks without changes for over 30 days' },
help: {
summary: {
ru: 'Открытые задачи, по которым не было никаких изменений более 30 дней',
en: 'Open tasks with no changes for more than 30 days',
},
details: {
ru: 'Карта «где гниёт работа» по проектам. Кандидаты либо на чистку (удалить устаревшее), либо на ре-активацию (если всё ещё актуально). Большое число на проекте = бэклог перегружен неактуальной работой, пора провести ревью.',
en: '"Where work rots" — per project. Candidates for cleanup (delete obsolete) or reactivation (if still relevant). A high number on a project means the backlog is bloated with obsolete work and needs a review.',
},
},
datasets: {
stale: { ru: 'Без движения >30д', en: 'No activity >30d' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.cycle_time_per_project': {
title: { ru: 'Cycle time по проектам', en: 'Cycle time per project' },
description: { ru: 'Медианное время выполнения закрытых задач', en: 'Median completion time for finished tasks' },
help: {
summary: {
ru: 'Сколько в среднем задача живёт от создания до закрытия в каждом проекте',
en: 'How long a task typically lives from creation to completion in each project',
},
details: {
ru: join([
'Что показывает:',
'Для каждого проекта — типичное время, за которое задача проходит путь от создания до закрытия. Используется медиана: «половина задач в этом проекте закрывается быстрее, чем за X дней».',
'',
'Какие задачи учитываются:',
'Только закрытые задачи, у которых дата закрытия попадает в выбранный период. Проекты без закрытых задач за период не показаны.',
'',
'Как читать:',
'Видно, какие проекты движутся быстрее, какие медленнее. Если один проект в 2-3 раза медленнее остальных — повод поговорить с его PM: возможно, мешают блокеры, задачи слишком крупные или процесс провисает.',
'',
'⚠ Не пугайтесь больших чисел:',
'',
'1. Считается полная жизнь задачи, а не время в работе.',
'Если задача 2 месяца лежала в бэклоге, а потом её сделали за 3 дня — здесь будет 63 дня. Реальное «время в работе» — только 3 из них. К сожалению, отделить «время лежания» от «времени работы» пока нельзя.',
'',
'2. Период фильтрует по дате закрытия, не создания.',
'Задача создана в январе, закрыта в апреле — попадёт в апрельский период. Её время = вся её жизнь (95 дней), а не «время за апрель».',
'',
'3. Используется медиана, а не среднее.',
'Один задавненный тикет, который наконец-то закрыли, не сломает показатель. Медиана говорит честно: «половина задач закрывается быстрее».',
'',
'4. Не сравнивайте напрямую разные по сути проекты.',
'Маркетинг с короткими постами и разработка с крупными фичами имеют разные «нормальные» значения. Сравнивайте проект сам с собой во времени, а не с соседями по списку.',
]),
en: join([
'What it shows:',
"For each project — the typical time a task spends from creation to completion. We use the median: \"half of this project's tasks close faster than X days\".",
'',
'Which tasks are counted:',
'Only closed tasks whose completion date falls within the selected period. Projects with no completions in the period are hidden.',
'',
'How to read it:',
"You can see which projects move faster and which slower. If one project is 23× slower than the rest, it's worth talking to its PM — there may be blockers, oversized tasks, or a sagging process.",
'',
"⚠ Don't panic over big numbers:",
'',
'1. We count the full task life, not just time in active work.',
"If a task sat in the backlog for 2 months and was then done in 3 days, it counts as 63 days. The actual \"in-progress\" time was only 3 days. We can't separate \"waiting\" from \"working\" yet.",
'',
'2. The period filters by completion date, not creation.',
'A task created in January and closed in April lands in the April period. Its time = its whole life (95 days), not "time during April".',
'',
'3. We use the median, not the mean.',
"A long-forgotten ticket that finally closed won't break the metric. The median says honestly: \"half of the tasks close faster\".",
'',
"4. Don't directly compare projects of different nature.",
'A marketing project with short posts and a dev project with large features have different normal values. Compare a project against itself over time, not against its neighbors in the list.',
]),
},
},
datasets: {
median: { ru: 'Медиана cycle time', en: 'Median cycle time' },
},
xAxisLabel: { ru: 'Дней', en: 'Days' },
},
// ===== Usage =====
'chart.status_distribution': {
title: { ru: 'Распределение по статусам', en: 'Status distribution' },
description: {
ru: 'Открытые задачи в колонках канбана',
en: 'Open tasks across kanban columns',
},
help: {
summary: {
ru: 'Доли открытых задач в каждой колонке канбана выбранного проекта',
en: 'Share of open tasks in each kanban column of the selected project',
},
details: {
ru: 'Моментальный снимок «где сейчас концентрация работы». Перекос в одну колонку (например, «In Review») = процесс застрял там, нужно разблокировать. Требует выбора проекта, потому что колонки канбана у каждого проекта свои.',
en: 'A snapshot of "where the work currently sits". A heavy skew into one column (e.g. "In Review") means the process is stuck there and needs unblocking. Requires selecting a project because each project has its own kanban columns.',
},
},
datasets: {
count: { ru: 'Задач', en: 'Tasks' },
},
},
'chart.active_projects': {
title: { ru: 'Активные vs мёртвые проекты', en: 'Active vs dead projects' },
description: { ru: 'По активности за 14 / 30 дней', en: 'By activity in last 14 / 30 days' },
help: {
summary: {
ru: 'Сколько проектов активны, затухают или мертвы по последней активности',
en: 'How many projects are active, fading, or dead by recent activity',
},
details: {
ru: join([
'Что показывает:',
'Все ваши проекты разбиты на 4 группы по тому, когда в них последний раз что-то делали:',
'• Активен — были изменения за последние 14 дней',
'• Затухает — последние правки 14-30 дней назад',
'• Мёртв — никакой активности более 30 дней',
'• Без задач — проект создан, но задач в нём нет',
'',
'Зачем смотреть:',
'Мёртвые и пустые проекты захламляют боковое меню — их можно архивировать, чтобы было видно только живое. Затухающие — повод проверить, всё ли в порядке (закрыли тему или забыли).',
'',
'Drill-down:',
'Кликните по столбцу или сектору, чтобы посмотреть открытые задачи в проектах этой категории. Особенно полезно для «мёртвых» — увидите, что лежит в заброшенных проектах.',
]),
en: join([
'What it shows:',
'All your projects split into 4 groups based on when something was last done in them:',
'• Active — there were edits in the last 14 days',
'• Fading — last edits 14-30 days ago',
'• Dead — no activity for over 30 days',
'• Empty — project exists but has no tasks',
'',
'Why look at it:',
'Dead and empty projects clutter the sidebar — archive them to keep only the live ones in view. Fading projects are worth a check — finished or forgotten.',
'',
'Drill-down:',
"Click a bar or sector to see open tasks in projects of that category. Especially useful for \"dead\" — see what's lying in abandoned projects.",
]),
},
},
datasets: {
count: { ru: 'Проектов', en: 'Projects' },
},
labels: {
active: { ru: 'Активен', en: 'Active' },
fading: { ru: 'Затухает', en: 'Fading' },
dead: { ru: 'Мёртв', en: 'Dead' },
empty: { ru: 'Без задач', en: 'Empty' },
},
yAxisLabel: { ru: 'Проектов', en: 'Projects' },
},
// ===== Financial =====
'chart.income_expense_month': {
title: { ru: 'Доходы и расходы по месяцам', en: 'Income and expense per month' },
description: { ru: 'Суммы завершённых финансовых задач', en: 'Amounts of completed financial tasks' },
help: {
summary: {
ru: 'Суммы завершённых финансовых задач, сгруппированные по месяцам',
en: 'Completed financial task amounts grouped by month',
},
details: {
ru: 'Требует заполнения полей Amount и Transaction Type на задачах. Используется командами, ведущими лёгкий финансовый трекинг в TaskView (частый кейс у small business). Для достоверности проверьте KPI «Заполнено amount» — при низком покрытии картина неполная.',
en: 'Requires the Amount and Transaction Type fields to be filled on tasks. Used by teams running lightweight financial tracking inside TaskView (common small-business case). Cross-check with the "Amount coverage" KPI — a low coverage means the picture is incomplete.',
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
'chart.income_expense_per_project': {
title: { ru: 'Доходы и расходы по проектам', en: 'Income and expense per project' },
description: {
ru: 'Сколько каждый проект принёс и сколько потратил',
en: 'How much each project earned and spent',
},
help: {
summary: {
ru: 'Доходы и расходы каждого проекта рядом, чтобы сравнить напрямую',
en: 'Income and expense for each project side-by-side for direct comparison',
},
details: {
ru: join([
'Что показывает:',
'Для каждого проекта — два столбца рядом: зелёный (доходы) и красный (расходы). Берутся суммы из задач, где у вас отмечена сумма и тип транзакции.',
'',
'Чем отличается от «Топ проектов по сумме»:',
'Там показан общий оборот — высота столбца = доходы + расходы вместе. Здесь — сравнение двух величин напрямую: видно, где проект зарабатывает больше, чем тратит, а где наоборот.',
'',
'Как читать:',
'• Зелёный выше красного → проект прибыльный',
'• Красный выше зелёного → проект пока в минус',
'• Оба маленькие → слабая активность или вы редко заполняете финансовые поля',
'',
'Сортировка — по чистой прибыли по убыванию: прибыльные проекты сверху.',
'',
'Что нужно для попадания в график:',
'У задачи должна быть указана сумма и тип (доход/расход). Если вы не пользуетесь финансовыми полями TaskView — этот график будет пустым. Чтобы понять качество данных, смотрите карточку «Заполнено amount».',
'',
'Что НЕ включено:',
'• Задачи без указанной суммы',
'• Задачи без типа транзакции',
'• Реальная прибыль после налогов и комиссий — TaskView показывает только то, что вы ввели сами',
]),
en: join([
'What it shows:',
"For each project — two side-by-side bars: green (income) and red (expense). The numbers come from tasks where you've filled in an amount and transaction type.",
'',
'Difference from "Top projects by amount":',
'That chart shows total turnover — bar height = income + expense combined. This one compares the two values directly: you can see which project earns more than it spends and vice versa.',
'',
'How to read it:',
'• Green taller than red → project is profitable',
'• Red taller than green → project is in the red so far',
'• Both small → weak activity or you rarely fill in financial fields',
'',
'Sorted by net profit descending: profitable projects on top.',
'',
"What's needed to appear on the chart:",
"A task needs both an amount and a type (income/expense). If you don't use TaskView's financial fields, this chart will be empty. To gauge data quality, check the \"Amount coverage\" card.",
'',
'What is NOT included:',
'• Tasks without an amount',
'• Tasks without a transaction type',
'• Real profit after taxes and fees — TaskView only shows what you enter yourself',
]),
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
'chart.top_projects_by_amount': {
title: { ru: 'Топ проектов по сумме', en: 'Top projects by amount' },
description: { ru: 'Суммарный доход и расход в каждом проекте', en: 'Total income and expense per project' },
help: {
summary: {
ru: 'Проекты, отсортированные по суммарному финансовому обороту',
en: 'Projects ranked by total financial turnover',
},
details: {
ru: 'Где крутятся деньги. Income + Expense как стек показывает полный оборот, а не только прибыль — так видно и затратные проекты, а не только прибыльные. Для сравнения чистой прибыли смотрите разницу сегментов.',
en: 'Shows where the money flows. Stacking income and expense reveals total turnover, not just profit — so expensive projects are visible, not only profitable ones. To compare net profit, eyeball the segment gap.',
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
xAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
} satisfies Record<string, SectionLocale>
export type SectionLocaleId = keyof typeof sectionLocales
@@ -0,0 +1,68 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class PriorityMixOverTimeSection implements SectionBuilder {
readonly id = 'chart.priority_mix'
readonly group = 'productivity' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const bucket = this.pickBucket(ctx.range.from, ctx.range.to)
const rows = await ctx.repository.fetchPriorityMix(ctx.accessibleGoalIds, ctx.range, bucket)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.bucket),
labelKind: 'date',
datasets: [
{ id: 'high', label: loc.datasets!.high, values: rows.map(r => Number(r.high)), colorToken: 'danger', stack: 'priority' },
{ id: 'medium', label: loc.datasets!.medium, values: rows.map(r => Number(r.medium)), colorToken: 'warning', stack: 'priority' },
{ id: 'low', label: loc.datasets!.low, values: rows.map(r => Number(r.low)), colorToken: 'info', stack: 'priority' },
{ id: 'none', label: loc.datasets!.none, values: rows.map(r => Number(r.none)), colorToken: 'neutral', stack: 'priority' },
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private pickBucket(from: Date, to: Date): 'day' | 'week' | 'month' {
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)
if (days <= 14) return 'day'
if (days <= 120) return 'week'
return 'month'
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,82 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class ThroughputSection implements SectionBuilder {
readonly id = 'chart.throughput'
readonly group = 'productivity' as const
readonly allowedChartTypes = ['area', 'line', 'bar'] as const
readonly defaultChartType = 'area' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const bucket = this.pickBucket(ctx.range.from, ctx.range.to)
const rows = await ctx.repository.fetchThroughput(ctx.accessibleGoalIds, ctx.range, bucket)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.bucket),
labelKind: 'date',
datasets: [
{
id: 'created',
label: loc.datasets!.created,
values: rows.map(r => Number(r.created)),
colorToken: 'info',
},
{
id: 'completed',
label: loc.datasets!.completed,
values: rows.map(r => Number(r.completed)),
colorToken: 'success',
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private pickBucket(from: Date, to: Date): 'day' | 'week' | 'month' {
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)
if (days <= 14) return 'day'
if (days <= 120) return 'week'
return 'month'
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'date',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,73 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeHistogramSection implements SectionBuilder {
readonly id = 'chart.cycle_time_histogram'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const row = await ctx.repository.fetchCycleTimeHistogram(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const labels = ['<1д', '13д', '37д', '714д', '1430д', '30+д']
const values = [
Number(row.bucket_0_1),
Number(row.bucket_1_3),
Number(row.bucket_3_7),
Number(row.bucket_7_14),
Number(row.bucket_14_30),
Number(row.bucket_30_plus),
]
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels,
labelKind: 'category',
datasets: [
{
id: 'tasks',
label: loc.datasets!.tasks,
values,
colorToken: 'primary',
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,63 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimePerProjectSection implements SectionBuilder {
readonly id = 'chart.cycle_time_per_project'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchCycleTimePerProject(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'median',
label: loc.datasets!.median,
values: rows.map(r => r.median_days === null || r.median_days === undefined ? 0 : Math.round(Number(r.median_days) * 10) / 10),
colorToken: 'info',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'days',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,93 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class OverdueByAgeSection implements SectionBuilder {
readonly id = 'chart.overdue_by_age'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const row = await ctx.repository.fetchOverdueByAge(ctx.accessibleGoalIds)
const loc = this.loc
const bucketKeys = ['bucket_1_3', 'bucket_4_7', 'bucket_8_14', 'bucket_15_plus'] as const
const labelTexts = bucketKeys.map(k => loc.labels![k])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: labelTexts.map(l => l.ru),
labelTexts,
labelKind: 'category',
datasets: [
{
id: 'overdue',
label: loc.datasets!.overdue,
values: [
Number(row.bucket_1_3),
Number(row.bucket_4_7),
Number(row.bucket_8_14),
Number(row.bucket_15_plus),
],
colorToken: 'danger',
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
const ranges: Array<[number, number | null]> = [
[1, 3],
[4, 7],
[8, 14],
[15, null],
]
const range = ranges[arg.index]
if (!range) return []
const [minDays, maxDays] = range
return ctx.repository.fetchOverdueTasksInRange(ctx.accessibleGoalIds, minDays, maxDays)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'category',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class StaleTasksSection implements SectionBuilder {
readonly id = 'chart.stale_tasks'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchStaleTasks(ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'stale',
label: loc.datasets!.stale,
values: rows.map(r => Number(r.stale)),
colorToken: 'warning',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const goalIds = arg.meta?.goalIds ?? []
const goalId = goalIds[arg.index]
if (!goalId || !ctx.accessibleGoalIds.includes(goalId)) return []
return ctx.repository.fetchStaleTasksInGoal(goalId, ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,130 @@
// SQL row types returned by each section's query.
// Field names match the SELECT aliases verbatim (snake_case where applicable).
// ===== KPI =====
export type CreatedTasksKpiRow = { count: number }
export type CompletedTasksKpiRow = { count: number }
export type OverdueKpiRow = { count: number }
export type CycleTimeKpiRow = { median: number | null }
export type TotalIncomeKpiRow = { total: number }
export type TotalExpenseKpiRow = { total: number }
export type PlannedIncomeKpiRow = { total: number }
export type PlannedExpenseKpiRow = { total: number }
export type NetProfitKpiRow = { income: number; expense: number }
export type AmountCoverageKpiRow = { total: number; with_amount: number }
// ===== Productivity =====
export type ThroughputSectionRow = {
bucket: string
created: number
completed: number
}
export type PriorityMixOverTimeSectionRow = {
bucket: string
high: number
medium: number
low: number
none: number
}
// ===== Workload =====
export type WorkloadByAssigneeSectionRow = {
user_id: number | null
user_name: string | null
high: number
medium: number
low: number
no_priority: number
}
export type BlockedByDependenciesSectionRow = {
goal_id: number
goal_name: string
blocked: number
}
export type TimeInKanbanStatusSectionRow = {
status_id: number | null
status_name: string | null
avg_days: number | null
task_count: number
}
export type AgingOpenTasksSectionRow = {
user_id: number | null
user_name: string | null
avg_age: number | null
max_age: number | null
task_count: number
}
// ===== Quality =====
export type OverdueByAgeSectionRow = {
bucket_1_3: number
bucket_4_7: number
bucket_8_14: number
bucket_15_plus: number
}
export type CycleTimeHistogramSectionRow = {
bucket_0_1: number
bucket_1_3: number
bucket_3_7: number
bucket_7_14: number
bucket_14_30: number
bucket_30_plus: number
}
export type StaleTasksSectionRow = {
goal_id: number
goal_name: string
stale: number
}
export type CycleTimePerProjectSectionRow = {
goal_id: number
goal_name: string
median_days: number | null
completed: number
}
// ===== Usage =====
export type StatusDistributionSectionRow = {
status_id: number | null
status_name: string | null
count: number
}
export type ActiveProjectsSectionRow = {
status_key: 'active' | 'fading' | 'dead' | 'empty'
count: number
}
// ===== Financial =====
export type IncomeExpenseMonthSectionRow = {
month: string
income: number
expense: number
}
export type IncomeExpensePerProjectSectionRow = {
goal_id: number
goal_name: string
income: number
expense: number
net: number
}
export type TopProjectsByAmountSectionRow = {
goal_id: number
goal_name: string
income: number
expense: number
}
@@ -0,0 +1,89 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
type StatusKey = 'active' | 'fading' | 'dead' | 'empty'
const COLOR_BY_STATUS: Record<StatusKey, 'success' | 'warning' | 'danger' | 'neutral'> = {
active: 'success',
fading: 'warning',
dead: 'danger',
empty: 'neutral',
}
export class ActiveProjectsSection implements SectionBuilder {
readonly id = 'chart.active_projects'
readonly group = 'usage' as const
readonly allowedChartTypes = ['bar', 'donut'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds)
const loc = this.loc
const labelTexts = rows.map(r => loc.labels![r.status_key])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: labelTexts.map(l => l.ru),
labelTexts,
labelKind: 'category',
datasets: [
{
id: 'count',
label: loc.datasets!.count,
values: rows.map(r => Number(r.count)),
meta: { statusKeys: rows.map(r => r.status_key) },
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
const firstRow = rows[0]
if (firstRow) {
payload.datasets[0].colorToken = COLOR_BY_STATUS[firstRow.status_key] ?? 'primary'
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
const statusKeys = arg.meta?.statusKeys ?? []
const statusKey = statusKeys[arg.index]
if (!statusKey || statusKey === 'empty') return []
return ctx.repository.fetchOpenTasksInActiveProjects(ctx.accessibleGoalIds, statusKey)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,64 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class StatusDistributionSection implements SectionBuilder {
readonly id = 'chart.status_distribution'
readonly group = 'usage' as const
readonly allowedChartTypes = ['donut', 'bar'] as const
readonly defaultChartType = 'donut' as const
readonly requiresGoalScope = true
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.scope.kind !== 'project') return null
const goalId = ctx.scope.goalId
if (!ctx.accessibleGoalIds.includes(goalId)) return null
const rows = await ctx.repository.fetchStatusDistribution(goalId, ctx.accessibleGoalIds)
const loc = this.loc
const topN = 6
let labels: string[]
let values: number[]
if (rows.length > topN) {
const top = rows.slice(0, topN - 1)
const rest = rows.slice(topN - 1)
labels = [...top.map(r => r.status_name ?? 'No status'), 'Другое']
values = [...top.map(r => Number(r.count)), rest.reduce((sum, r) => sum + Number(r.count), 0)]
} else {
labels = rows.map(r => r.status_name ?? 'No status')
values = rows.map(r => Number(r.count))
}
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels,
labelKind: 'category',
datasets: [
{
id: 'count',
label: loc.datasets!.count,
values,
},
],
unit: 'count',
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,80 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class AgingOpenTasksSection implements SectionBuilder {
readonly id = 'chart.aging_open_tasks'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar', 'line', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchAgingOpenTasks(ctx.accessibleGoalIds)
const loc = this.loc
const userIds = rows.map(r => r.user_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.user_name ?? 'Unknown'),
labelKind: 'category',
datasets: [
{
id: 'avg_age',
label: loc.datasets!.avg_age,
values: rows.map(r => r.avg_age === null || r.avg_age === undefined ? 0 : Math.round(Number(r.avg_age) * 10) / 10),
colorToken: 'warning',
meta: { userIds },
},
{
id: 'max_age',
label: loc.datasets!.max_age,
values: rows.map(r => r.max_age === null || r.max_age === undefined ? 0 : Math.round(Number(r.max_age) * 10) / 10),
colorToken: 'danger',
meta: { userIds },
},
],
unit: 'days',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const userIds = arg.meta?.userIds ?? []
const userId = userIds[arg.index]
if (!userId || ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchOpenTasksAssignedTo(ctx.accessibleGoalIds, userId)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class BlockedByDependenciesSection implements SectionBuilder {
readonly id = 'chart.blocked_by_deps'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchBlockedByDeps(ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'blocked',
label: loc.datasets!.blocked,
values: rows.map(r => Number(r.blocked)),
colorToken: 'danger',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const goalIds = arg.meta?.goalIds ?? []
const goalId = goalIds[arg.index]
if (!goalId || !ctx.accessibleGoalIds.includes(goalId)) return []
return ctx.repository.fetchBlockedTasksInGoal(goalId, ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,53 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TimeInKanbanStatusSection implements SectionBuilder {
readonly id = 'chart.time_in_kanban_status'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly requiresGoalScope = true
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.scope.kind !== 'project') return null
const goalId = ctx.scope.goalId
if (!ctx.accessibleGoalIds.includes(goalId)) return null
const rows = await ctx.repository.fetchTimeInKanbanStatus(goalId, ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.status_name ?? 'Без статуса'),
labelKind: 'category',
datasets: [
{
id: 'avg_days',
label: loc.datasets!.avg_days,
values: rows.map(r => r.avg_days === null || r.avg_days === undefined ? 0 : Math.round(Number(r.avg_days) * 10) / 10),
colorToken: 'info',
},
],
unit: 'days',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,117 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class WorkloadByAssigneeSection implements SectionBuilder {
readonly id = 'chart.workload_by_assignee'
readonly group = 'workload' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea', 'bar', 'line', 'area'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchWorkloadByAssignee(ctx.accessibleGoalIds)
const loc = this.loc
const userIds = rows.map(r => r.user_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.user_name ?? 'Unknown'),
labelKind: 'category',
datasets: [
{
id: 'high',
label: loc.datasets!.high,
values: rows.map(r => Number(r.high)),
colorToken: 'danger',
stack: 'priority',
meta: { userIds },
},
{
id: 'medium',
label: loc.datasets!.medium,
values: rows.map(r => Number(r.medium)),
colorToken: 'warning',
stack: 'priority',
meta: { userIds },
},
{
id: 'low',
label: loc.datasets!.low,
values: rows.map(r => Number(r.low)),
colorToken: 'info',
stack: 'priority',
meta: { userIds },
},
{
id: 'no_priority',
label: loc.datasets!.no_priority,
values: rows.map(r => Number(r.no_priority)),
colorToken: 'neutral',
stack: 'priority',
meta: { userIds },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const userIds = arg.meta?.userIds ?? []
const userId = userIds[arg.index]
if (!userId || ctx.accessibleGoalIds.length === 0) return []
const priorityByDataset: Record<string, number | 'null'> = {
high: 3,
medium: 2,
low: 1,
no_priority: 'null',
}
const priorityFilter = priorityByDataset[arg.datasetId]
if (priorityFilter === undefined) return []
return ctx.repository.fetchOpenTasksAssignedWithPriority(
ctx.accessibleGoalIds,
userId,
priorityFilter,
)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'category',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { type } from 'arktype'
import type {
AnalyticsChartType,
AnalyticsPeriod,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionGroup,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
import type { AnalyticsRepository } from './AnalyticsRepository'
const positiveIntFromQuery = type('string | number')
.pipe((v) => Number(v))
.narrow((n, ctx) => Number.isInteger(n) && n > 0 ? true : ctx.mustBe('a positive integer'))
const nonNegativeIntFromQuery = type('string | number')
.pipe((v) => Number(v))
.narrow((n, ctx) => Number.isInteger(n) && n >= 0 ? true : ctx.mustBe('a non-negative integer'))
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'from?': 'string',
'to?': 'string',
'sections?': 'string',
})
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
'datasetId?': 'string',
'meta?': 'string',
'index?': nonNegativeIntFromQuery,
})
export const DrillDownMetaArkType = type({
'goalIds?': 'number[]',
'userIds?': 'number[]',
'statusKeys?': "('active' | 'fading' | 'dead' | 'empty')[]",
})
export type DrillDownMeta = typeof DrillDownMetaArkType.infer
export type AnalyticsRange = {
from: Date
to: Date
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
period: AnalyticsPeriod
range: AnalyticsRange
accessibleGoalIds: number[]
repository: AnalyticsRepository
}
export type SectionDrillDownArg = {
bucket: string
index: number
datasetId: string
meta?: DrillDownMeta
}
export type DrillDownTaskRow = {
id: number
description: string
goalId: number
goalName: string
complete: boolean
priorityId: number | null
endDate: string | null
date_creation: string
date_complete: string | null
}
export interface SectionBuilder {
readonly id: string
readonly group: AnalyticsSectionGroup
readonly allowedChartTypes: readonly AnalyticsChartType[]
readonly defaultChartType: AnalyticsChartType | null
readonly requiresGoalScope?: boolean
readonly cacheTtlSec?: number
build(ctx: BuilderContext): Promise<AnalyticsSection | null>
drillDown?(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]>
}
export type AnalyticsArgBuildSections = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
sectionIds?: string[]
}
export type AnalyticsArgDrillDown = {
sectionId: string
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
arg: SectionDrillDownArg
}
+47 -35
View File
@@ -17,9 +17,12 @@ import {
import { generateString, isEmail, time } from '../../utils/helpers';
import EnEmailTemplate from './mail/confirm-email-en';
import RuEmailTemplate from './mail/confirm-email-ru';
import LoginCodeEmailTemplate from './mail/login-code-en';
import type { ExternalAuthUser } from './strategies/external-auth.types';
import { OrganizationRepository } from '../organizations/OrganizationRepository';
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
export default class AuthController {
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
private readonly jwtExp: string = process.env.ACCESS_LIFE_TIME!;
@@ -104,7 +107,8 @@ export default class AuthController {
}
generateLoginCode() {
return `${this.makeidLogin(12)}:${Date.now()}`.toLocaleLowerCase();
const code = String(randomInt(100000, 1000000));
return `${code}:${Date.now()}`;
}
/**
* Register user by email and send login code to the email
@@ -165,26 +169,36 @@ export default class AuthController {
const lastUpdate = userData.remember_token?.split(':')[1];
const now = Date.now();
const RESEND_COOLDOWN_MS = 60 * 1000;
if (!lastUpdate || (lastUpdate && now - +lastUpdate > 60 * 1000)) {
$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`);
await this.sendCodeByEmail(code.split(':')[0], email);
if (lastUpdate && now - +lastUpdate < RESEND_COOLDOWN_MS) {
return res.status(429).send({
message: 'Please wait before requesting another code.',
retryAfter: Math.ceil((RESEND_COOLDOWN_MS - (now - +lastUpdate)) / 1000),
});
}
$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`);
this.sendCodeByEmail(code.split(':')[0], email)
.then((ok) => {
if (!ok) $logger.error({ email }, 'Failed to send login code email');
})
.catch((err) => $logger.error({ err, email }, 'Failed to send login code email'));
return res.status(200).end();
};
async sendCodeByEmail(code: string, email: string) {
const text = `Your TaskView verification code is ${code}\n\nUse this code to sign in. The code expires in 5 minutes.\n\nIf you didn't request this code, ignore this email.`;
const html = LoginCodeEmailTemplate.replace('{code}', code);
return await Email.send({
text: null,
text,
to: email,
subject: 'Code',
subject: `Your TaskView code: ${code}`,
from: process.env.SMTP_FROM_EMAIL as string,
attachment: [{ data: `<span>Code <strong>${code}</strong></span>`, alternative: true }],
attachment: [{ data: html, alternative: true }],
});
}
@@ -295,8 +309,8 @@ export default class AuthController {
loginByCode = async (req: Request, res: Response) => {
const schema = z.object({
email: z.string().email().toLowerCase(),
code: z.string().min(12).toLowerCase(),
email: z.string().trim().email().toLowerCase(),
code: z.string().trim().regex(/^\d{6}$/, '6-digit code'),
});
const data = schema.safeParse(req.body);
@@ -323,7 +337,7 @@ export default class AuthController {
return res.status(400).send({ message: 'Invalid code' });
}
if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > 60 * 1000) {
if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > LOGIN_CODE_TTL_MS) {
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
return res.status(400).send({ message: 'Code expired, get new code' });
}
@@ -507,48 +521,44 @@ export default class AuthController {
return res.status(400).send();
}
// Always respond 200 to avoid user-enumeration. SMTP send runs in background,
// so the response never races the browser preflight/timeout.
const respond = () => res.status(200).send({ sent: true });
const userData = await req.appUser.authManager.repository.getUserByLogin(data.data.email, true);
if (!userData) {
return res.status(400).send();
}
if (!this.canSendRemindEmail(userData)) {
return res.status(400).send();
if (!userData || !this.canSendRemindEmail(userData)) {
return respond();
}
const code = generateString(18);
const seconds = time();
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`);
return res.status(500).send();
$logger.error('Can not set remind_code and time for user');
return respond();
}
const remindPasswordUrl = `${process.env.APP_URL}/login/?resetCode=${code}&login=${userData.login}`;
const remindPasswordBody = `<html>
<body>
<p><a href="${remindPasswordUrl}"> Reset password link! </a></p>
</body>
</html>`;
const sendResult = await Email.send({
Email.send({
text: null,
to: userData.email, //'gimanhead@gmail.com',
to: userData.email,
subject: 'Remind password!',
from: process.env.SMTP_FROM_EMAIL as string,
attachment: [{ data: remindPasswordBody, alternative: true }],
});
// const sendResult = await Email.send('', 'Remind password', 'gimanhead@gmail.com', remindPasswordBody);
})
.then((ok) => {
if (!ok) $logger.error({ to: userData.email }, 'Failed to send remind password email');
})
.catch((err) => $logger.error({ err, to: userData.email }, 'Failed to send remind password email'));
if (sendResult) {
return res.status(200).send({ sent: true });
}
return res.status(500).send();
return respond();
};
changeRemindedPassword = async (req: Request, res: Response) => {
@@ -580,6 +590,8 @@ export default class AuthController {
return res.status(500).send();
}
await req.appUser.authManager.repository.setReminderCodeAndTime(userData.email, null, null);
return res.status(200).send({ reset: true });
};
@@ -0,0 +1,45 @@
export default `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="format-detection" content="telephone=no" />
<meta name="color-scheme" content="only" />
<title>TaskView verification code</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
<tr>
<td align="center" style="padding: 40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
<tr>
<td style="padding: 40px 32px 24px; text-align: center;">
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
</td>
</tr>
<tr>
<td style="padding: 0 32px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Your verification code</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 24px; text-align: center;">
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #71717a;">Use the code below to sign in. It expires in 5 minutes.</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 0 32px 32px;">
<div style="display: inline-block; padding: 20px 28px; background-color: #f4f4f5; border-radius: 10px; font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 36px; font-weight: 600; letter-spacing: 8px; color: #18181b;">{code}</div>
</td>
</tr>
<tr>
<td style="padding: 0 32px 40px; text-align: center;">
<p style="margin: 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">If you didn't request this code, you can safely ignore this email.</p>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
</td>
</tr>
</table>
</body>
</html>`
@@ -1,11 +1,18 @@
import type { AppUser } from '../../core/AppUser'
import { isNotNullable } from '../../utils/helpers'
import { OrganizationRepository } from './OrganizationRepository'
import type { OrganizationArgCreate, OrganizationArgUpdate } from './types'
import {
OrgRoles,
type OrganizationArgCreate,
type OrganizationArgUpdate,
} from './types'
type OrgMember = Awaited<ReturnType<OrganizationRepository['getMemberByEmail']>>
export class OrganizationManager {
public readonly repository: OrganizationRepository
private readonly user: AppUser
private readonly memberCache: Map<number, OrgMember> = new Map()
constructor(user: AppUser) {
this.user = user
@@ -40,9 +47,10 @@ export class OrganizationManager {
async update(data: OrganizationArgUpdate) {
if (data.slug) {
data = { ...data, slug: data.slug.toLowerCase() }
const existing = await this.repository.findBySlug(data.slug)
const slug = data.slug.toLowerCase()
const existing = await this.repository.findBySlug(slug)
if (existing && existing.id !== data.organizationId) return false
data = { ...data, slug }
}
return await this.repository.update(data)
@@ -115,9 +123,20 @@ export class OrganizationManager {
}
async getCurrentUserMember(orgId: number) {
if (this.memberCache.has(orgId)) {
return this.memberCache.get(orgId)!
}
const email = this.getUserEmail()
if (!email) return false
return await this.repository.getMemberByEmail(orgId, email)
const member = await this.repository.getMemberByEmail(orgId, email)
this.memberCache.set(orgId, member)
return member
}
async isCurrentUserOrgOwner(orgId: number): Promise<boolean> {
const member = await this.getCurrentUserMember(orgId)
if (!member) return false
return member.role === OrgRoles.OWNER
}
private generateSlug(): string {
@@ -0,0 +1,161 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { logError } from '../../utils/api'
import { parsePositiveInt } from '../../utils/helpers'
import {
TimeEntryArkTypeCreate,
TimeEntryArkTypeDelete,
TimeEntryArkTypeFetchEntries,
TimeEntryArkTypeHistory,
TimeEntryArkTypeStart,
TimeEntryArkTypeStop,
TimeEntryArkTypeSummaryByGoal,
TimeEntryArkTypeSummaryByTask,
TimeEntryArkTypeUpdate,
TimeReportArkTypeFilters,
} from './types'
export class TimeTrackingController {
start = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeStart(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await req.appUser.timeTrackingManager.start(data).catch(logError)
if (!result) return res.status(403).end()
return res.tvJson(result)
}
stop = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeStop(req.body ?? {})
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const entry = await req.appUser.timeTrackingManager.stop(data).catch(logError)
if (!entry) return res.status(404).end()
return res.tvJson(entry)
}
active = async (req: Request, res: Response) => {
const entry = await req.appUser.timeTrackingManager.getActive().catch(logError)
return res.tvJson(entry ?? null)
}
createManual = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const entry = await req.appUser.timeTrackingManager.createManual(data).catch(logError)
if (!entry) return res.status(400).end()
return res.tvJson(entry)
}
update = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeUpdate({ ...req.body, id: Number(req.params.id) })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const entry = await req.appUser.timeTrackingManager.update(data).catch(logError)
if (!entry) return res.status(403).end()
return res.tvJson(entry)
}
delete = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeDelete({ id: req.params.id })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const ok = await req.appUser.timeTrackingManager.delete(data.id).catch(logError)
if (!ok) return res.status(403).end()
return res.tvJson({ deleted: true })
}
fetchEntries = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeFetchEntries(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const entries = await req.appUser.timeTrackingManager.fetchEntries(data).catch(logError)
return res.tvJson(entries ?? [])
}
summaryByTask = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeSummaryByTask({ taskId: req.params.taskId })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const summary = await req.appUser.timeTrackingManager.summaryByTask(data.taskId).catch(logError)
if (!summary) return res.status(403).end()
return res.tvJson(summary)
}
summaryByGoal = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeSummaryByGoal({ goalId: req.params.goalId })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const summary = await req.appUser.timeTrackingManager.summaryByGoal(data.goalId).catch(logError)
if (!summary) return res.status(403).end()
return res.tvJson(summary)
}
fetchHistory = async (req: Request, res: Response) => {
const data = TimeEntryArkTypeHistory({ id: req.params.id })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const history = await req.appUser.timeTrackingManager.fetchHistory(data.id).catch(logError)
if (!history) return res.status(403).end()
return res.tvJson(history)
}
private parseReportQuery(req: Request) {
const organizationId = parsePositiveInt(req.query?.organizationId)
if (organizationId === null) return { error: 'orgId' as const }
const filters = TimeReportArkTypeFilters(req.query)
if (filters instanceof ArkErrors) return { error: 'filters' as const, summary: filters.summary }
return { request: { organizationId, filters } }
}
reportByDay = async (req: Request, res: Response) => {
const parsed = this.parseReportQuery(req)
if ('error' in parsed) {
if (parsed.error === 'orgId') return res.status(400).end()
return res.status(400).send(parsed.summary)
}
const rows = await req.appUser.timeTrackingManager.reportByDay(parsed.request).catch(logError)
return res.tvJson(rows ?? [])
}
reportByUser = async (req: Request, res: Response) => {
const parsed = this.parseReportQuery(req)
if ('error' in parsed) {
if (parsed.error === 'orgId') return res.status(400).end()
return res.status(400).send(parsed.summary)
}
const rows = await req.appUser.timeTrackingManager.reportByUser(parsed.request).catch(logError)
return res.tvJson(rows ?? [])
}
reportByTask = async (req: Request, res: Response) => {
const parsed = this.parseReportQuery(req)
if ('error' in parsed) {
if (parsed.error === 'orgId') return res.status(400).end()
return res.status(400).send(parsed.summary)
}
const rows = await req.appUser.timeTrackingManager.reportByTask(parsed.request).catch(logError)
return res.tvJson(rows ?? [])
}
reportSummary = async (req: Request, res: Response) => {
const parsed = this.parseReportQuery(req)
if ('error' in parsed) {
if (parsed.error === 'orgId') return res.status(400).end()
return res.status(400).send(parsed.summary)
}
const summary = await req.appUser.timeTrackingManager.reportSummary(parsed.request).catch(logError)
return res.tvJson(summary ?? { totalSeconds: 0, totalBillableSeconds: 0, entriesCount: 0 })
}
reportContributors = async (req: Request, res: Response) => {
const parsed = this.parseReportQuery(req)
if ('error' in parsed) {
if (parsed.error === 'orgId') return res.status(400).end()
return res.status(400).send(parsed.summary)
}
const rows = await req.appUser.timeTrackingManager.reportContributors(parsed.request).catch(logError)
return res.tvJson(rows ?? [])
}
}
@@ -0,0 +1,89 @@
import { getCentrifugoClient } from '../../core/CentrifugoClient'
import type { Dispatcher } from '../../core/Dispatcher'
import { eventBus, type AppEvents } from '../../core/EventBus'
import { getJobQueue } from '../../core/JobQueue'
import { $logger } from '../../modules/logget'
import { TimeTrackingRepository } from './TimeTrackingRepository'
const AUTOSTOP_JOB = 'time-tracking-autostop'
export class TimeTrackingDispatcher implements Dispatcher {
private readonly repository = new TimeTrackingRepository()
register(): void {
eventBus.on('time-entry.started', (data) => this.onStarted(data))
eventBus.on('time-entry.stopped', (data) => this.onStopped(data))
eventBus.on('time-entry.created', (data) => this.onCreated(data))
eventBus.on('time-entry.updated', (data) => this.onUpdated(data))
eventBus.on('time-entry.deleted', (data) => this.onDeleted(data))
}
async registerWorkers(): Promise<void> {
const boss = getJobQueue()
await boss.createQueue(AUTOSTOP_JOB)
await boss.schedule(AUTOSTOP_JOB, '0 * * * *')
await boss.work(AUTOSTOP_JOB, async () => {
await this.runAutostop()
})
}
private async runAutostop(): Promise<void> {
const stoppedEntries = await this.repository.autoStopOverdue()
if (stoppedEntries.length === 0) return
for (const entry of stoppedEntries) {
eventBus.emit('time-entry.stopped', {
entry,
taskId: entry.taskId,
userId: entry.userId,
goalId: entry.goalId,
durationSeconds: entry.durationSeconds ?? 0,
})
}
$logger.info({ count: stoppedEntries.length }, '[TimeTracking] Autostop completed')
}
private async onStarted(data: AppEvents['time-entry.started']): Promise<void> {
await this.publish(data.userId, 'time-entry.started', { entry: data.entry })
}
private async onStopped(data: AppEvents['time-entry.stopped']): Promise<void> {
await this.publish(data.userId, 'time-entry.stopped', {
entry: data.entry,
durationSeconds: data.durationSeconds,
})
}
private async onCreated(data: AppEvents['time-entry.created']): Promise<void> {
await this.publish(data.entry.userId, 'time-entry.created', { entry: data.entry })
}
private async onUpdated(data: AppEvents['time-entry.updated']): Promise<void> {
await this.publish(data.entry.userId, 'time-entry.updated', {
entry: data.entry,
changes: data.changes,
})
if (data.initiatorId !== data.entry.userId) {
await this.publish(data.initiatorId, 'time-entry.updated', {
entry: data.entry,
changes: data.changes,
})
}
}
private async onDeleted(data: AppEvents['time-entry.deleted']): Promise<void> {
await this.publish(data.userId, 'time-entry.deleted', { entryId: data.entryId, taskId: data.taskId })
if (data.initiatorId !== data.userId) {
await this.publish(data.initiatorId, 'time-entry.deleted', {
entryId: data.entryId,
taskId: data.taskId,
})
}
}
private async publish(userId: number, event: string, payload: Record<string, unknown>): Promise<void> {
const centrifugo = getCentrifugoClient()
await centrifugo.publishToUser(userId, event, payload)
}
}
@@ -0,0 +1,348 @@
import type { AppUser } from '../../core/AppUser'
import { eventBus } from '../../core/EventBus'
import { GoalPermissions } from '../../types/auth.types'
import { TimeTrackingRepository } from './TimeTrackingRepository'
import {
TIME_ENTRY_SOURCE,
type TimeEntryArgCreate,
type TimeEntryArgFetchEntries,
type TimeEntryArgStart,
type TimeEntryArgStop,
type TimeEntryArgUpdate,
type TimeEntryStartResult,
type TimeEntryUpdateParams,
type TimeEntryWithUser,
type TimeReportByDayRow,
type TimeReportByTaskRow,
type TimeReportByUserRow,
type TimeReportContributor,
type TimeReportRepoFilters,
type TimeReportRequest,
type TimeReportSummary,
} from './types'
type ResolvedReportFilters = TimeReportRepoFilters
export class TimeTrackingManager {
public readonly repository: TimeTrackingRepository
private readonly user: AppUser
constructor(user: AppUser) {
this.user = user
this.repository = new TimeTrackingRepository()
}
private getCurrentUserId(): number | null {
return this.user.getUserData()?.id ?? null
}
private computeDuration(startedAt: Date, endedAt: Date): number {
return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000))
}
async getActive(): Promise<TimeEntryWithUser | null> {
const userId = this.getCurrentUserId()
if (!userId) return null
return this.repository.findActiveForUser(userId)
}
async start(data: TimeEntryArgStart): Promise<TimeEntryStartResult | null> {
const userId = this.getCurrentUserId()
if (!userId) return null
const task = await this.repository.fetchTaskWithGoal(data.taskId)
if (!task) return null
for (let attempt = 0; attempt < 2; attempt++) {
const autoStoppedEntry = await this.closeActiveTimer(userId)
const insertResult = await this.repository.tryInsertActiveTimer({
taskId: task.id,
goalId: task.goalId,
userId,
description: data.description ?? null,
source: TIME_ENTRY_SOURCE.TIMER,
})
if (insertResult.entry) {
const entry = insertResult.entry
eventBus.emit('time-entry.started', {
entry,
taskId: entry.taskId,
userId: entry.userId,
goalId: entry.goalId,
})
return { entry, autoStoppedEntry }
}
if (!insertResult.conflict) return null
}
return null
}
private async closeActiveTimer(userId: number): Promise<TimeEntryWithUser | null> {
const stopped = await this.repository.closeActiveAtomicForUser(userId, new Date())
if (!stopped) return null
eventBus.emit('time-entry.stopped', {
entry: stopped,
taskId: stopped.taskId,
userId: stopped.userId,
goalId: stopped.goalId,
durationSeconds: stopped.durationSeconds ?? 0,
})
return stopped
}
async stop(data: TimeEntryArgStop): Promise<TimeEntryWithUser | null> {
const userId = this.getCurrentUserId()
if (!userId) return null
const target = data.entryId
? await this.repository.findById(data.entryId)
: await this.repository.findActiveForUser(userId)
if (!target || target.endedAt) return null
const endedAt = new Date()
const stopped = await this.repository.updateById(target.id, {
endedAt,
durationSeconds: this.computeDuration(target.startedAt, endedAt),
})
if (!stopped) return null
eventBus.emit('time-entry.stopped', {
entry: stopped,
taskId: stopped.taskId,
userId: stopped.userId,
goalId: stopped.goalId,
durationSeconds: stopped.durationSeconds ?? 0,
})
return stopped
}
async createManual(data: TimeEntryArgCreate): Promise<TimeEntryWithUser | null> {
const userId = this.getCurrentUserId()
if (!userId) return null
if (data.endedAt.getTime() <= data.startedAt.getTime()) return null
const task = await this.repository.fetchTaskWithGoal(data.taskId)
if (!task) return null
const entry = await this.repository.insert({
taskId: task.id,
goalId: task.goalId,
userId,
startedAt: data.startedAt,
endedAt: data.endedAt,
durationSeconds: this.computeDuration(data.startedAt, data.endedAt),
description: data.description ?? null,
source: TIME_ENTRY_SOURCE.MANUAL,
billable: data.billable,
})
if (!entry) return null
eventBus.emit('time-entry.created', { entry, initiatorId: userId })
return entry
}
async update(data: TimeEntryArgUpdate): Promise<TimeEntryWithUser | null> {
const userId = this.getCurrentUserId()
if (!userId) return null
const existing = await this.repository.findById(data.id)
if (!existing) return null
if (existing.endedAt === null) {
const onlyDescription =
data.description !== undefined &&
data.startedAt === undefined &&
data.endedAt === undefined &&
data.billable === undefined
if (!onlyDescription) return null
}
const updates: TimeEntryUpdateParams = {}
const startedAt = data.startedAt ?? existing.startedAt
const endedAt = data.endedAt ?? existing.endedAt
if (data.startedAt !== undefined) updates.startedAt = data.startedAt
if (data.endedAt !== undefined) updates.endedAt = data.endedAt
if (data.description !== undefined) updates.description = data.description
if (data.billable !== undefined) updates.billable = data.billable
if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined)) {
if (endedAt.getTime() <= startedAt.getTime()) return null
updates.durationSeconds = this.computeDuration(startedAt, endedAt)
}
const updated = await this.repository.updateById(existing.id, updates)
if (!updated) return null
eventBus.emit('time-entry.updated', {
entry: updated,
changes: updates as Record<string, unknown>,
initiatorId: userId,
})
return updated
}
async delete(id: number): Promise<boolean> {
const userId = this.getCurrentUserId()
if (!userId) return false
const existing = await this.repository.findById(id)
if (!existing) return false
const deleted = await this.repository.deleteById(id)
if (!deleted) return false
eventBus.emit('time-entry.deleted', {
entryId: existing.id,
taskId: existing.taskId,
goalId: existing.goalId,
userId: existing.userId,
initiatorId: userId,
})
return true
}
async fetchEntries(filters: TimeEntryArgFetchEntries): Promise<TimeEntryWithUser[]> {
const userId = this.getCurrentUserId()
if (!userId) return []
let goalId = filters.goalId
if (goalId === undefined && filters.taskId !== undefined) {
const task = await this.repository.fetchTaskWithGoal(filters.taskId)
if (!task) return []
goalId = task.goalId
}
const hasSingleScope = goalId !== undefined
let goalIds: number[]
if (hasSingleScope) {
goalIds = [goalId!]
} else {
if (filters.organizationId === undefined) return []
const accessibleGoalIds = await this.user.permissionsFetcher.getAccessibleGoalIds(
filters.organizationId,
[GoalPermissions.TIMETRACKING_CAN_VIEW, GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL],
)
if (accessibleGoalIds.length === 0) return []
const requested = filters.goalIds && filters.goalIds.length > 0 ? filters.goalIds : null
goalIds = requested
? requested.filter((id) => accessibleGoalIds.includes(id))
: accessibleGoalIds
if (goalIds.length === 0) return []
}
return this.repository.fetchEntries({
goalIds,
taskId: filters.taskId,
userId: filters.userId,
billable: filters.billable,
from: filters.from,
to: filters.to,
limit: filters.limit,
offset: filters.offset,
})
}
async summaryByTask(taskId: number) {
const userId = this.getCurrentUserId()
if (!userId) return null
const task = await this.repository.fetchTaskWithGoal(taskId)
if (!task) return null
return this.repository.sumByTask(taskId)
}
async summaryByGoal(goalId: number) {
const userId = this.getCurrentUserId()
if (!userId) return null
return this.repository.sumByGoal(goalId)
}
async fetchHistory(entryId: number) {
const userId = this.getCurrentUserId()
if (!userId) return null
const existing = await this.repository.findById(entryId)
if (!existing) return null
return this.repository.fetchHistory(entryId)
}
async getViewableGoalIds(organizationId: number): Promise<number[]> {
return this.user.permissionsFetcher.getAccessibleGoalIds(organizationId, [
GoalPermissions.TIMETRACKING_CAN_VIEW,
GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL,
])
}
private async resolveReportFilters(req: TimeReportRequest): Promise<ResolvedReportFilters | null> {
const data = this.user.getUserData()
if (!data) return null
const allowedIds = await this.getViewableGoalIds(req.organizationId)
if (allowedIds.length === 0) return null
const requestedIds = req.filters.goalIds && req.filters.goalIds.length > 0 ? req.filters.goalIds : null
const goalIds = requestedIds
? requestedIds.filter((id) => allowedIds.includes(id))
: allowedIds
if (goalIds.length === 0) return null
return {
goalIds,
userId: req.filters.userId,
from: req.filters.from,
to: req.filters.to,
billable: req.filters.billable,
timezone: req.filters.timezone,
}
}
async reportByDay(req: TimeReportRequest): Promise<TimeReportByDayRow[]> {
const resolved = await this.resolveReportFilters(req)
if (!resolved) return []
return this.repository.aggregateByDay(resolved)
}
async reportByUser(req: TimeReportRequest): Promise<TimeReportByUserRow[]> {
const resolved = await this.resolveReportFilters(req)
if (!resolved) return []
return this.repository.aggregateByUser(resolved)
}
async reportByTask(req: TimeReportRequest): Promise<TimeReportByTaskRow[]> {
const resolved = await this.resolveReportFilters(req)
if (!resolved) return []
return this.repository.aggregateByTask(resolved)
}
async reportSummary(req: TimeReportRequest): Promise<TimeReportSummary> {
const resolved = await this.resolveReportFilters(req)
if (!resolved) {
return { totalSeconds: 0, totalBillableSeconds: 0, entriesCount: 0 }
}
return this.repository.getReportSummary(resolved)
}
async reportContributors(req: TimeReportRequest): Promise<TimeReportContributor[]> {
const resolved = await this.resolveReportFilters(req)
if (!resolved) return []
return this.repository.fetchContributors({ ...resolved, userId: undefined })
}
}
@@ -0,0 +1,419 @@
import { and, desc, eq, gte, inArray, isNull, lte, sql, type SQL } from 'drizzle-orm'
import {
TimeEntriesSchema,
TimeEntriesHistorySchema,
TasksSchema,
UsersSchema,
type TimeEntriesHistorySchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { $logger } from '../../modules/logget'
import { callWithCatch } from '../../utils/helpers'
import type {
TimeEntryFilters,
TimeEntryGoalSummary,
TimeEntryInsertParams,
TimeEntryInsertResult,
TimeEntryTaskSummary,
TimeEntryUpdateParams,
TimeEntryWithUser,
TimeReportByDayRow,
TimeReportByTaskRow,
TimeReportByUserRow,
TimeReportContributor,
TimeReportRepoFilters,
TimeReportSummary,
} from './types'
const PG_UNIQUE_VIOLATION = '23505'
export class TimeTrackingRepository {
private readonly db: Database
private static readonly entryWithUserProjection = {
id: TimeEntriesSchema.id,
taskId: TimeEntriesSchema.taskId,
goalId: TimeEntriesSchema.goalId,
userId: TimeEntriesSchema.userId,
startedAt: TimeEntriesSchema.startedAt,
endedAt: TimeEntriesSchema.endedAt,
durationSeconds: TimeEntriesSchema.durationSeconds,
description: TimeEntriesSchema.description,
source: TimeEntriesSchema.source,
billable: TimeEntriesSchema.billable,
autoStopped: TimeEntriesSchema.autoStopped,
createdAt: TimeEntriesSchema.createdAt,
editedAt: TimeEntriesSchema.editedAt,
userEmail: UsersSchema.email,
}
constructor() {
this.db = Database.getInstance()
}
async fetchTaskWithGoal(taskId: number): Promise<{ id: number; goalId: number } | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: TasksSchema.id, goalId: TasksSchema.goalId })
.from(TasksSchema)
.where(eq(TasksSchema.id, taskId)),
)
return result?.[0] ?? null
}
async findActiveForUser(userId: number): Promise<TimeEntryWithUser | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select(TimeTrackingRepository.entryWithUserProjection)
.from(TimeEntriesSchema)
.leftJoin(UsersSchema, eq(UsersSchema.id, TimeEntriesSchema.userId))
.where(and(eq(TimeEntriesSchema.userId, userId), isNull(TimeEntriesSchema.endedAt))),
)
return result?.[0] ?? null
}
async closeActiveAtomicForUser(userId: number, endedAt: Date): Promise<TimeEntryWithUser | null> {
const updated = await callWithCatch(() =>
this.db.dbDrizzle
.update(TimeEntriesSchema)
.set({
endedAt,
durationSeconds: sql<number>`greatest(0, round(extract(epoch from ${endedAt}::timestamp - ${TimeEntriesSchema.startedAt}))::int)`,
})
.where(and(eq(TimeEntriesSchema.userId, userId), isNull(TimeEntriesSchema.endedAt)))
.returning({ id: TimeEntriesSchema.id }),
)
if (!updated || updated.length === 0) return null
return this.findById(updated[0].id)
}
async findById(id: number): Promise<TimeEntryWithUser | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select(TimeTrackingRepository.entryWithUserProjection)
.from(TimeEntriesSchema)
.leftJoin(UsersSchema, eq(UsersSchema.id, TimeEntriesSchema.userId))
.where(eq(TimeEntriesSchema.id, id)),
)
return result?.[0] ?? null
}
async findByIds(ids: number[]): Promise<TimeEntryWithUser[]> {
if (ids.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select(TimeTrackingRepository.entryWithUserProjection)
.from(TimeEntriesSchema)
.leftJoin(UsersSchema, eq(UsersSchema.id, TimeEntriesSchema.userId))
.where(inArray(TimeEntriesSchema.id, ids)),
)
return result ?? []
}
async insert(data: TimeEntryInsertParams): Promise<TimeEntryWithUser | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(TimeEntriesSchema)
.values({
taskId: data.taskId,
goalId: data.goalId,
userId: data.userId,
startedAt: data.startedAt,
endedAt: data.endedAt ?? null,
durationSeconds: data.durationSeconds ?? null,
description: data.description ?? null,
source: data.source,
billable: data.billable ?? true,
})
.returning({ id: TimeEntriesSchema.id }),
)
const id = result?.[0]?.id
if (!id) return null
return this.findById(id)
}
async tryInsertActiveTimer(data: TimeEntryInsertParams): Promise<TimeEntryInsertResult> {
try {
const result = await this.db.dbDrizzle
.insert(TimeEntriesSchema)
.values({
taskId: data.taskId,
goalId: data.goalId,
userId: data.userId,
startedAt: data.startedAt,
endedAt: data.endedAt ?? null,
durationSeconds: data.durationSeconds ?? null,
description: data.description ?? null,
source: data.source,
billable: data.billable ?? true,
})
.returning({ id: TimeEntriesSchema.id })
const id = result?.[0]?.id
if (!id) return { entry: null, conflict: false }
const entry = await this.findById(id)
return { entry, conflict: false }
} catch (error) {
const code = (error as { code?: string } | null)?.code
if (code === PG_UNIQUE_VIOLATION) {
return { entry: null, conflict: true }
}
$logger.error(error, 'TimeTrackingRepository.tryInsertActiveTimer')
return { entry: null, conflict: false }
}
}
async updateById(id: number, data: TimeEntryUpdateParams): Promise<TimeEntryWithUser | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(TimeEntriesSchema)
.set(data)
.where(eq(TimeEntriesSchema.id, id))
.returning({ id: TimeEntriesSchema.id }),
)
const updatedId = result?.[0]?.id
if (!updatedId) return null
return this.findById(updatedId)
}
async deleteById(id: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(TimeEntriesSchema).where(eq(TimeEntriesSchema.id, id)),
)
return !!result?.rowCount
}
async fetchEntries(filters: TimeEntryFilters): Promise<TimeEntryWithUser[]> {
if (filters.goalIds !== undefined && filters.goalIds.length === 0) return []
const conditions: SQL[] = []
if (filters.goalIds !== undefined) conditions.push(inArray(TimeEntriesSchema.goalId, filters.goalIds))
if (filters.taskId !== undefined) conditions.push(eq(TimeEntriesSchema.taskId, filters.taskId))
if (filters.userId !== undefined) conditions.push(eq(TimeEntriesSchema.userId, filters.userId))
if (filters.billable !== undefined) conditions.push(eq(TimeEntriesSchema.billable, filters.billable))
if (filters.from !== undefined) conditions.push(gte(TimeEntriesSchema.startedAt, filters.from))
if (filters.to !== undefined) conditions.push(lte(TimeEntriesSchema.startedAt, filters.to))
const limit = Math.min(filters.limit ?? 50, 500)
const offset = filters.offset ?? 0
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select(TimeTrackingRepository.entryWithUserProjection)
.from(TimeEntriesSchema)
.leftJoin(UsersSchema, eq(UsersSchema.id, TimeEntriesSchema.userId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(TimeEntriesSchema.startedAt))
.limit(limit)
.offset(offset),
)
return result ?? []
}
async sumByTask(taskId: number): Promise<TimeEntryTaskSummary> {
const total = await callWithCatch(() =>
this.db.dbDrizzle
.select({ total: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int` })
.from(TimeEntriesSchema)
.where(eq(TimeEntriesSchema.taskId, taskId)),
)
const byUser = await callWithCatch(() =>
this.db.dbDrizzle
.select({
userId: TimeEntriesSchema.userId,
seconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
})
.from(TimeEntriesSchema)
.where(eq(TimeEntriesSchema.taskId, taskId))
.groupBy(TimeEntriesSchema.userId),
)
return {
totalSeconds: total?.[0]?.total ?? 0,
byUser: byUser ?? [],
}
}
async sumByGoal(goalId: number): Promise<TimeEntryGoalSummary> {
const total = await callWithCatch(() =>
this.db.dbDrizzle
.select({ total: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int` })
.from(TimeEntriesSchema)
.where(eq(TimeEntriesSchema.goalId, goalId)),
)
const byUser = await callWithCatch(() =>
this.db.dbDrizzle
.select({
userId: TimeEntriesSchema.userId,
seconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
})
.from(TimeEntriesSchema)
.where(eq(TimeEntriesSchema.goalId, goalId))
.groupBy(TimeEntriesSchema.userId),
)
const byTask = await callWithCatch(() =>
this.db.dbDrizzle
.select({
taskId: TimeEntriesSchema.taskId,
seconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
})
.from(TimeEntriesSchema)
.where(eq(TimeEntriesSchema.goalId, goalId))
.groupBy(TimeEntriesSchema.taskId),
)
return {
totalSeconds: total?.[0]?.total ?? 0,
byUser: byUser ?? [],
byTask: byTask ?? [],
}
}
async fetchHistory(entryId: number): Promise<TimeEntriesHistorySchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(TimeEntriesHistorySchema)
.where(eq(TimeEntriesHistorySchema.entryId, entryId))
.orderBy(desc(TimeEntriesHistorySchema.editDate)),
)
return result ?? []
}
async autoStopOverdue(): Promise<TimeEntryWithUser[]> {
const updated = await callWithCatch(() =>
this.db.dbDrizzle
.update(TimeEntriesSchema)
.set({
endedAt: sql`now()`,
autoStopped: true,
durationSeconds: sql<number>`extract(epoch from (now() - ${TimeEntriesSchema.startedAt}))::int`,
})
.where(
and(
isNull(TimeEntriesSchema.endedAt),
sql`${TimeEntriesSchema.id} IN (
SELECT te.id
FROM tasks.time_entries te
JOIN tasks.goals g ON g.id = te.goal_id
JOIN tv_auth.organizations o ON o.id = g.organization_id
WHERE te.ended_at IS NULL
AND o.time_tracking_autostop_hours IS NOT NULL
AND te.started_at < now() - (o.time_tracking_autostop_hours || ' hours')::interval
)`,
),
)
.returning({ id: TimeEntriesSchema.id }),
)
const ids = (updated ?? []).map((r) => r.id)
return this.findByIds(ids)
}
private reportConditions(filters: TimeReportRepoFilters): SQL[] {
const conditions: SQL[] = [
inArray(TimeEntriesSchema.goalId, filters.goalIds),
gte(TimeEntriesSchema.startedAt, filters.from),
lte(TimeEntriesSchema.startedAt, filters.to),
]
if (filters.userId !== undefined) conditions.push(eq(TimeEntriesSchema.userId, filters.userId))
if (filters.billable !== undefined) conditions.push(eq(TimeEntriesSchema.billable, filters.billable))
return conditions
}
async aggregateByDay(filters: TimeReportRepoFilters): Promise<TimeReportByDayRow[]> {
if (filters.goalIds.length === 0) return []
const tzLiteral = filters.timezone ? `'${filters.timezone.replace(/'/g, "''")}'` : null
const dayExpr = tzLiteral
? sql<string>`to_char((${TimeEntriesSchema.startedAt} AT TIME ZONE 'UTC' AT TIME ZONE ${sql.raw(tzLiteral)})::date, 'YYYY-MM-DD')`
: sql<string>`to_char(${TimeEntriesSchema.startedAt}::date, 'YYYY-MM-DD')`
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
day: dayExpr,
totalSeconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
entriesCount: sql<number>`count(*)::int`,
})
.from(TimeEntriesSchema)
.where(and(...this.reportConditions(filters)))
.groupBy(dayExpr)
.orderBy(dayExpr),
)
return result ?? []
}
async aggregateByUser(filters: TimeReportRepoFilters): Promise<TimeReportByUserRow[]> {
if (filters.goalIds.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
userId: TimeEntriesSchema.userId,
userEmail: UsersSchema.email,
totalSeconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
entriesCount: sql<number>`count(*)::int`,
})
.from(TimeEntriesSchema)
.leftJoin(UsersSchema, eq(UsersSchema.id, TimeEntriesSchema.userId))
.where(and(...this.reportConditions(filters)))
.groupBy(TimeEntriesSchema.userId, UsersSchema.email)
.orderBy(sql`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0) desc`),
)
return result ?? []
}
async aggregateByTask(filters: TimeReportRepoFilters): Promise<TimeReportByTaskRow[]> {
if (filters.goalIds.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
taskId: TimeEntriesSchema.taskId,
taskDescription: TasksSchema.description,
goalId: TimeEntriesSchema.goalId,
totalSeconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
entriesCount: sql<number>`count(*)::int`,
})
.from(TimeEntriesSchema)
.leftJoin(TasksSchema, eq(TasksSchema.id, TimeEntriesSchema.taskId))
.where(and(...this.reportConditions(filters)))
.groupBy(TimeEntriesSchema.taskId, TasksSchema.description, TimeEntriesSchema.goalId)
.orderBy(sql`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0) desc`),
)
return result ?? []
}
async getReportSummary(filters: TimeReportRepoFilters): Promise<TimeReportSummary> {
const empty: TimeReportSummary = { totalSeconds: 0, totalBillableSeconds: 0, entriesCount: 0 }
if (filters.goalIds.length === 0) return empty
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
totalSeconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
totalBillableSeconds: sql<number>`coalesce(sum(case when ${TimeEntriesSchema.billable} then ${TimeEntriesSchema.durationSeconds} else 0 end), 0)::int`,
entriesCount: sql<number>`count(*)::int`,
})
.from(TimeEntriesSchema)
.where(and(...this.reportConditions(filters))),
)
return result?.[0] ?? empty
}
async fetchContributors(filters: TimeReportRepoFilters): Promise<TimeReportContributor[]> {
if (filters.goalIds.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
userId: TimeEntriesSchema.userId,
userEmail: UsersSchema.email,
totalSeconds: sql<number>`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0)::int`,
entriesCount: sql<number>`count(*)::int`,
})
.from(TimeEntriesSchema)
.leftJoin(UsersSchema, eq(UsersSchema.id, TimeEntriesSchema.userId))
.where(and(...this.reportConditions(filters)))
.groupBy(TimeEntriesSchema.userId, UsersSchema.email)
.orderBy(sql`coalesce(sum(${TimeEntriesSchema.durationSeconds}), 0) desc`),
)
return result ?? []
}
}
@@ -0,0 +1,47 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { TimeTrackingController } from './TimeTrackingController'
import { canLogTimeOnTask } from './middlewares/can-log-time-on-task'
import { canStopTimer } from './middlewares/can-stop-timer'
import { canAccessTimeEntry } from './middlewares/can-access-time-entry'
import { canFetchTimeEntries } from './middlewares/can-fetch-time-entries'
import { canViewActiveTimer } from './middlewares/can-view-active-timer'
import { canViewTimeStats } from './middlewares/can-view-time-stats'
import { isOrgMemberForReports } from './middlewares/is-org-member-for-reports'
export default class TimeTrackingRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: TimeTrackingController
constructor() {
this.router = Router()
this.controller = new TimeTrackingController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
this.router.post('/start', [IsLoggedIn, canLogTimeOnTask], this.controller.start)
this.router.post('/stop', [IsLoggedIn, canStopTimer], this.controller.stop)
this.router.get('/active', [IsLoggedIn, canViewActiveTimer], this.controller.active)
this.router.post('/entries', [IsLoggedIn, canLogTimeOnTask], this.controller.createManual)
this.router.patch('/entries/:id', [IsLoggedIn, canAccessTimeEntry('edit')], this.controller.update)
this.router.delete('/entries/:id', [IsLoggedIn, canAccessTimeEntry('edit')], this.controller.delete)
this.router.get('/entries', [IsLoggedIn, canFetchTimeEntries], this.controller.fetchEntries)
this.router.get('/entries/:id/history', [IsLoggedIn, canAccessTimeEntry('view')], this.controller.fetchHistory)
this.router.get('/summary/task/:taskId', [IsLoggedIn, canViewTimeStats({ kind: 'task', param: 'taskId' })], this.controller.summaryByTask)
this.router.get('/summary/goal/:goalId', [IsLoggedIn, canViewTimeStats({ kind: 'goal', param: 'goalId' })], this.controller.summaryByGoal)
this.router.get('/reports/summary', [IsLoggedIn, isOrgMemberForReports], this.controller.reportSummary)
this.router.get('/reports/by-day', [IsLoggedIn, isOrgMemberForReports], this.controller.reportByDay)
this.router.get('/reports/by-user', [IsLoggedIn, isOrgMemberForReports], this.controller.reportByUser)
this.router.get('/reports/by-task', [IsLoggedIn, isOrgMemberForReports], this.controller.reportByTask)
this.router.get('/reports/contributors', [IsLoggedIn, isOrgMemberForReports], this.controller.reportContributors)
}
}
@@ -0,0 +1,37 @@
import type { NextFunction, Request, Response } from 'express'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
type Action = 'view' | 'edit'
export const canAccessTimeEntry = (action: Action) => async (req: Request, res: Response, next: NextFunction) => {
const entryId = Number(req.params.id)
if (!entryId) return res.status(400).end()
const entry = await req.appUser.timeTrackingManager.repository.findById(entryId).catch(logError)
if (!entry) return res.status(404).end()
const checker = await req.appUser.permissionsFetcher
.getCheckerForGoal(entry.goalId)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for canAccessTimeEntry middleware')
return res.status(500).end()
}
if (action === 'view') {
if (
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_VIEW) ||
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL)
) {
return next()
}
return res.status(403).end()
}
if (checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL)) return next()
return res.status(403).end()
}
@@ -0,0 +1,43 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
const TT_PERMS: string[] = [
GoalPermissions.TIMETRACKING_CAN_VIEW,
GoalPermissions.TIMETRACKING_CAN_LOG,
GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL,
]
export const canFetchTimeEntries = async (req: Request, res: Response, next: NextFunction) => {
const goalIdRaw = req.query.goalId
const taskIdRaw = req.query.taskId
const tokenPerms = req.appUser.getTokenPermissions()
if (tokenPerms && tokenPerms.length > 0 && !TT_PERMS.some((p) => tokenPerms.includes(p))) {
return res.status(403).end()
}
if (!goalIdRaw && !taskIdRaw) return next()
const checker = goalIdRaw
? await req.appUser.permissionsFetcher.getCheckerForGoal(Number(goalIdRaw)).catch(logError)
: await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskIdRaw), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for canFetchTimeEntries middleware')
return res.status(500).end()
}
if (
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_VIEW) ||
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL)
) {
return next()
}
return res.status(403).end()
}
@@ -0,0 +1,28 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
export const canLogTimeOnTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = Number(req.body?.taskId)
if (!taskId) return res.status(400).end()
const checker = await req.appUser.permissionsFetcher
.getPermissionsForType(taskId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for canLogTimeOnTask middleware')
return res.status(500).end()
}
if (
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_LOG) ||
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL)
) {
return next()
}
return res.status(403).end()
}
@@ -0,0 +1,31 @@
import type { NextFunction, Request, Response } from 'express'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
export const canStopTimer = async (req: Request, res: Response, next: NextFunction) => {
const userId = req.appUser.getUserData()!.id
const repo = req.appUser.timeTrackingManager.repository
const entryIdRaw = req.body?.entryId
const target = entryIdRaw
? await repo.findById(Number(entryIdRaw)).catch(logError)
: await repo.findActiveForUser(userId).catch(logError)
if (!target || target.endedAt) return res.status(404).end()
const checker = await req.appUser.permissionsFetcher
.getCheckerForGoal(target.goalId)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for canStopTimer middleware')
return res.status(500).end()
}
const isOwn = target.userId === userId
if (isOwn && checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_LOG)) return next()
if (checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL)) return next()
return res.status(403).end()
}
@@ -0,0 +1,29 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
const TT_PERMS: string[] = [
GoalPermissions.TIMETRACKING_CAN_VIEW,
GoalPermissions.TIMETRACKING_CAN_LOG,
GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL,
]
export const canViewActiveTimer = async (req: Request, res: Response, next: NextFunction) => {
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const tokenPerms = req.appUser.getTokenPermissions()
if (tokenPerms && tokenPerms.length > 0 && !TT_PERMS.some((p) => tokenPerms.includes(p))) {
return res.status(403).end()
}
const allowedGoalIds = req.appUser.getAllowedGoalIds()
if (allowedGoalIds && allowedGoalIds.length > 0) {
const active = await req.appUser.timeTrackingManager.repository
.findActiveForUser(userId)
.catch(logError)
if (active && !allowedGoalIds.includes(active.goalId)) return res.status(403).end()
}
return next()
}
@@ -0,0 +1,34 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
type Source = { kind: 'goal'; param: string } | { kind: 'task'; param: string }
export const canViewTimeStats = (source: Source) => async (req: Request, res: Response, next: NextFunction) => {
const idRaw = req.params[source.param] ?? req.query[source.param]
const id = Number(idRaw)
if (!id) return res.status(400).end()
const checker =
source.kind === 'task'
? await req.appUser.permissionsFetcher
.getPermissionsForType(id, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError)
: await req.appUser.permissionsFetcher.getCheckerForGoal(id).catch(logError)
if (!checker) {
$logger.error('Can not get permissions for canViewTimeStats middleware')
return res.status(500).end()
}
if (
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_VIEW) ||
checker.hasPermissions(GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL)
) {
return next()
}
return res.status(403).end()
}
@@ -0,0 +1,24 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissions } from '../../../types/auth.types'
import { parsePositiveInt } from '../../../utils/helpers'
const TT_PERMS: string[] = [
GoalPermissions.TIMETRACKING_CAN_VIEW,
GoalPermissions.TIMETRACKING_CAN_LOG,
GoalPermissions.TIMETRACKING_CAN_MANAGE_ALL,
]
export const isOrgMemberForReports = async (req: Request, res: Response, next: NextFunction) => {
const orgId = parsePositiveInt(req.query?.organizationId)
if (orgId === null) return res.status(400).end()
const tokenPerms = req.appUser.getTokenPermissions()
if (tokenPerms && tokenPerms.length > 0 && !TT_PERMS.some((p) => tokenPerms.includes(p))) {
return res.status(403).end()
}
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
if (!member) return res.status(403).end()
return next()
}
+238
View File
@@ -0,0 +1,238 @@
import { type } from 'arktype'
import type { TimeEntriesSchemaTypeForSelect } from 'taskview-db-schemas'
export type TimeEntryWithUser = TimeEntriesSchemaTypeForSelect & {
userEmail: string | null
}
const NumberFromString = type('string|number').pipe((v) => Number(v))
const OptionalNumberFromString = type('string|number|undefined').pipe((v) => v === undefined ? undefined : Number(v))
const DateFromString = type('string|Date').pipe((v) => v instanceof Date ? v : new Date(v))
const OptionalDateFromString = type('string|Date|undefined').pipe((v) => v === undefined ? undefined : v instanceof Date ? v : new Date(v))
const NumberListFromString = type('string|number|number[]|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'number') return [v]
if (Array.isArray(v)) return v.map((n) => Number(n)).filter((n) => Number.isFinite(n))
return v.split(',').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
})
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
const DESCRIPTION_MAX = 500
const Description = type('string').narrow((v, ctx) => {
const codepoints = [...v].length
return codepoints <= DESCRIPTION_MAX || ctx.mustBe(`at most ${DESCRIPTION_MAX} characters (got ${codepoints})`)
})
export const TimeEntryArkTypeStart = type({
taskId: 'number',
'description?': Description,
})
export type TimeEntryArgStart = typeof TimeEntryArkTypeStart.infer
export const TimeEntryArkTypeStop = type({
'entryId?': 'number',
})
export type TimeEntryArgStop = typeof TimeEntryArkTypeStop.infer
export const TimeEntryArkTypeCreate = type({
taskId: 'number',
startedAt: DateFromString,
endedAt: DateFromString,
'description?': Description,
'billable?': 'boolean',
})
export type TimeEntryArgCreate = typeof TimeEntryArkTypeCreate.infer
export const TimeEntryArkTypeUpdate = type({
id: 'number',
'startedAt?': OptionalDateFromString,
'endedAt?': OptionalDateFromString,
'description?': Description,
'billable?': 'boolean',
})
export type TimeEntryArgUpdate = typeof TimeEntryArkTypeUpdate.infer
export const TimeEntryArkTypeDelete = type({
id: NumberFromString,
})
export type TimeEntryArgDelete = typeof TimeEntryArkTypeDelete.infer
export const TimeEntryArkTypeFetchEntries = type({
'organizationId?': OptionalNumberFromString,
'goalId?': OptionalNumberFromString,
'goalIds?': NumberListFromString,
'taskId?': OptionalNumberFromString,
'userId?': OptionalNumberFromString,
'billable?': BooleanFromString,
'from?': OptionalDateFromString,
'to?': OptionalDateFromString,
'limit?': OptionalNumberFromString,
'offset?': OptionalNumberFromString,
})
export type TimeEntryArgFetchEntries = typeof TimeEntryArkTypeFetchEntries.infer
export const TimeEntryArkTypeSummaryByGoal = type({
goalId: NumberFromString,
})
export type TimeEntryArgSummaryByGoal = typeof TimeEntryArkTypeSummaryByGoal.infer
export const TimeEntryArkTypeSummaryByTask = type({
taskId: NumberFromString,
})
export type TimeEntryArgSummaryByTask = typeof TimeEntryArkTypeSummaryByTask.infer
export const TimeEntryArkTypeHistory = type({
id: NumberFromString,
})
export type TimeEntryArgHistory = typeof TimeEntryArkTypeHistory.infer
export const TIME_ENTRY_SOURCE = {
TIMER: 0,
MANUAL: 1,
} as const
const Timezone = type('string').narrow((v, ctx) => {
try {
new Intl.DateTimeFormat('en-US', { timeZone: v })
return true
}
catch {
return ctx.mustBe('a valid IANA timezone name')
}
})
const MAX_REPORT_WINDOW_MS = 2 * 365 * 24 * 60 * 60 * 1000
export const TimeReportArkTypeFilters = type({
'goalIds?': NumberListFromString,
'userId?': OptionalNumberFromString,
from: DateFromString,
to: DateFromString,
'billable?': BooleanFromString,
'timezone?': Timezone,
}).narrow((data, ctx) => {
const fromMs = data.from.getTime()
const toMs = data.to.getTime()
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
return ctx.mustBe('valid from/to dates')
}
if (fromMs >= toMs) {
return ctx.mustBe('from earlier than to')
}
if (toMs - fromMs > MAX_REPORT_WINDOW_MS) {
return ctx.mustBe('range no wider than 2 years')
}
return true
})
export type TimeReportFilters = typeof TimeReportArkTypeFilters.infer
export type TimeReportRequest = {
organizationId: number
filters: TimeReportFilters
}
export type TimeReportRepoFilters = {
goalIds: number[]
userId?: number
from: Date
to: Date
billable?: boolean
timezone?: string
}
export type TimeReportByDayRow = {
day: string
totalSeconds: number
entriesCount: number
}
export type TimeReportByUserRow = {
userId: number
userEmail: string | null
totalSeconds: number
entriesCount: number
}
export type TimeReportByTaskRow = {
taskId: number
taskDescription: string | null
goalId: number
totalSeconds: number
entriesCount: number
}
export type TimeReportSummary = {
totalSeconds: number
totalBillableSeconds: number
entriesCount: number
}
export type TimeReportContributor = {
userId: number
userEmail: string | null
totalSeconds: number
entriesCount: number
}
export type TimeEntrySource = typeof TIME_ENTRY_SOURCE[keyof typeof TIME_ENTRY_SOURCE]
export type TimeEntryInsertParams = {
taskId: number
goalId: number
userId: number
source: TimeEntrySource
startedAt?: Date
endedAt?: Date | null
durationSeconds?: number | null
description?: string | null
billable?: boolean
}
export type TimeEntryUpdateParams = Partial<{
startedAt: Date
endedAt: Date | null
durationSeconds: number | null
description: string | null
billable: boolean
autoStopped: boolean
}>
export type TimeEntryFilters = {
goalIds?: number[]
taskId?: number
userId?: number
billable?: boolean
from?: Date
to?: Date
limit?: number
offset?: number
}
export type TimeEntryStartResult = {
entry: TimeEntryWithUser
autoStoppedEntry: TimeEntryWithUser | null
}
export type TimeEntryInsertResult = {
entry: TimeEntryWithUser | null
conflict: boolean
}
export type TimeEntryUserSeconds = { userId: number; seconds: number }
export type TimeEntryTaskSummary = {
totalSeconds: number
byUser: TimeEntryUserSeconds[]
}
export type TimeEntryGoalSummary = {
totalSeconds: number
byUser: TimeEntryUserSeconds[]
byTask: { taskId: number; seconds: number }[]
}
@@ -23,6 +23,11 @@ export class WebhooksDispatcher implements Dispatcher {
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));
eventBus.on('time-entry.started', (data) => this.dispatch('time-entry.started', data.goalId, data));
eventBus.on('time-entry.stopped', (data) => this.dispatch('time-entry.stopped', data.goalId, data));
eventBus.on('time-entry.created', (data) => this.dispatch('time-entry.created', data.entry.goalId, data));
eventBus.on('time-entry.updated', (data) => this.dispatch('time-entry.updated', data.entry.goalId, data));
eventBus.on('time-entry.deleted', (data) => this.dispatch('time-entry.deleted', data.goalId, data));
}
async registerWorkers(): Promise<void> {
+5
View File
@@ -52,6 +52,11 @@ export const WEBHOOK_EVENTS = [
'task.updated',
'task.deleted',
'task.assigneesChanged',
'time-entry.started',
'time-entry.stopped',
'time-entry.created',
'time-entry.updated',
'time-entry.deleted',
] as const;
export type WebhookEvent = typeof WEBHOOK_EVENTS[number];
+13
View File
@@ -126,9 +126,22 @@ export const GoalPermissions = {
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
ANALYTICS_CAN_VIEW: 'analytics_can_view',
TIMETRACKING_CAN_VIEW: 'timetracking_can_view',
TIMETRACKING_CAN_LOG: 'timetracking_can_log',
TIMETRACKING_CAN_MANAGE_ALL: 'timetracking_can_manage_all',
} as const;
export type PermissionsEntityType =
| typeof GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL
| typeof GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASKLIST
| typeof GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK;
export type FetchGoalIdsWithAnyPermissionParams = {
userId: number;
email: string;
organizationId: number;
permissionNames: string[];
};
+11
View File
@@ -60,6 +60,17 @@ export function parseDeviceName(userAgent: string | undefined): string {
return parts.length > 0 ? parts.join(', ') : 'Unknown'
}
export function parsePositiveInt(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isInteger(value) && value > 0 ? value : null
}
if (typeof value === 'string' && /^\d+$/.test(value)) {
const n = Number(value)
return Number.isSafeInteger(n) && n > 0 ? n : null
}
return null
}
export const chunk = <T>(array: T[], size: number): T[][] => {
if (!Array.isArray(array)) {
throw new TypeError('Expected array');
+46
View File
@@ -138,6 +138,9 @@ importers:
semver:
specifier: ^7.6.3
version: 7.7.3
taskview-api:
specifier: workspace:^
version: link:../taskview-packages/taskview-api
taskview-db-schemas:
specifier: workspace:^
version: link:../taskview-packages/taskview-db-schemas
@@ -415,6 +418,12 @@ importers:
centrifuge:
specifier: ^5.5.3
version: 5.5.3
chart.js:
specifier: ^4.5.1
version: 4.5.1
chartjs-plugin-annotation:
specifier: ^3.1.0
version: 3.1.0(chart.js@4.5.1)
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -439,6 +448,9 @@ importers:
vue:
specifier: ^3.5.27
version: 3.5.27(typescript@5.9.3)
vue-chartjs:
specifier: ^5.3.3
version: 5.3.3(chart.js@4.5.1)(vue@3.5.27(typescript@5.9.3))
vue-i18n:
specifier: ^11.2.8
version: 11.2.8(vue@3.5.27(typescript@5.9.3))
@@ -2124,6 +2136,9 @@ packages:
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@@ -3804,6 +3819,7 @@ packages:
'@xmldom/xmldom@0.9.9':
resolution: {integrity: sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==}
engines: {node: '>=14.6'}
deprecated: this version has critical issues, please update to the latest version
JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
@@ -4255,6 +4271,15 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
chartjs-plugin-annotation@3.1.0:
resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==}
peerDependencies:
chart.js: '>=4.0.0'
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -8498,6 +8523,12 @@ packages:
vt-pbf@3.1.3:
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
vue-chartjs@5.3.3:
resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==}
peerDependencies:
chart.js: ^4.1.1
vue: ^3.0.0-0 || ^2.7.0
vue-component-type-helpers@3.2.5:
resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==}
@@ -10672,6 +10703,8 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
'@kurkle/color@0.3.4': {}
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@@ -13266,6 +13299,14 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
chartjs-plugin-annotation@3.1.0(chart.js@4.5.1):
dependencies:
chart.js: 4.5.1
check-error@2.1.3: {}
chevrotain@7.1.1:
@@ -17819,6 +17860,11 @@ snapshots:
'@mapbox/vector-tile': 1.3.1
pbf: 3.3.0
vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.27(typescript@5.9.3)):
dependencies:
chart.js: 4.5.1
vue: 3.5.27(typescript@5.9.3)
vue-component-type-helpers@3.2.5: {}
vue-demi@0.14.10(vue@3.5.27(typescript@5.9.3)):
@@ -0,0 +1,563 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, initApi } from './init-api'
describe('Analytics access control', () => {
let user1Api: TvApi
let user2Api: TvApi
let user2Email: string
let user1AuthHeader: string
let deleteAllGoals: () => Promise<void>
let analyticsPermissionId: number
const createdOrgIds: number[] = []
const createdApiTokenIds: number[] = []
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user2Api = init.$tvApiForSecondUser
user2Email = init.user2Email
deleteAllGoals = init.deleteAllGoals
user1AuthHeader = user1Api['$axios'].defaults.headers.common['Authorization'] as string
const allPermissions = await user1Api.collaboration.fetchAllPermissions()
const found = allPermissions.find(p => p.name === TvPermissions.ANALYTICS_CAN_VIEW)
if (!found) {
throw new Error(
'Permission "analytics_can_view" is not in DB. '
+ 'Run migration 1.46.0/0.add-analytics-permission.sql.',
)
}
analyticsPermissionId = found.id
})
afterAll(async () => {
await deleteAllGoals()
for (const id of createdApiTokenIds) {
await user1Api.apiTokens.delete(id).catch(() => {})
}
for (const orgId of createdOrgIds) {
await user1Api.organizations.delete(orgId).catch(() => {})
await user2Api.organizations.delete(orgId).catch(() => {})
}
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
const actual = e.response?.status
expect(actual, `Expected ${status}, got ${actual}`).toBe(status)
}
}
/**
* Create a fresh org owned by user1, with two projects.
* user2 is added as a member (no analytics permission yet).
*/
async function setupSharedOrg(label: string) {
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const org = await user1Api.organizations.create({ name: `Analytics ${label} ${ts}` })
createdOrgIds.push(org.id)
const projectA = await user1Api.goals.createGoal({
name: `Project A ${ts}`,
organizationId: org.id,
})
const projectB = await user1Api.goals.createGoal({
name: `Project B ${ts}`,
organizationId: org.id,
})
if (!projectA || !projectB) throw new Error('Failed to create project goals')
await user1Api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'member',
})
return { orgId: org.id, projectA, projectB }
}
/**
* Grant `analytics_can_view` permission on `goalId` to user2.
* Verifies the toggle actually turned the permission ON (defends against
* future regression if `createRoleForGoal` ever ships with default permissions).
*/
async function grantAnalyticsViewToUser2(goalId: number) {
const collab = await user1Api.collaboration.inviteUserToGoal({
email: user2Email,
goalId,
})
if (!collab) throw new Error('Failed to invite user2 as collaborator')
const role = await user1Api.collaboration.createRoleForGoal({
goalId,
roleName: `Analytics Viewer ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggleResult = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: analyticsPermissionId,
})
if (!toggleResult || toggleResult.add !== true) {
throw new Error(
`Expected toggleRolePermission to add the permission (add=true), got ${JSON.stringify(toggleResult)}. `
+ 'This likely means createRoleForGoal now ships with default permissions; tests need to be updated.',
)
}
await user1Api.collaboration.toggleUserRoles({
goalId,
userId: collab.id,
roles: [role.id],
})
}
describe('Authentication (HTTP 401 / 403)', () => {
it('anonymous request → 401', async () => {
const noAuth = axios.create({ baseURL: API_URL })
await expectHttpStatus(
noAuth.get('/module/analytics/sections', {
params: { scope: 'org', organizationId: 1, period: '30d' },
}),
401,
)
})
it('API-token request → 403 (RejectApiTokenAuth)', async () => {
const created = await user1Api.apiTokens.create({ name: `Analytics test ${Date.now()}` })
if (!created?.token) throw new Error('Failed to create API token')
createdApiTokenIds.push(created.item.id)
const tokenAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
})
// Need a real organizationId for the token user; use any (middleware order
// checks RejectApiTokenAuth before org membership)
await expectHttpStatus(
tokenAxios.get('/module/analytics/sections', {
params: { scope: 'org', organizationId: 1, period: '30d' },
}),
403,
)
})
})
describe('Validation (HTTP 400)', () => {
it('rejects request without organizationId', async () => {
await expectHttpStatus(
// @ts-expect-error -- intentionally missing organizationId
user1Api.analytics.fetchSections({ scope: { kind: 'org' }, period: '30d' }),
400,
)
})
it('rejects scope=project without goalId', async () => {
const { orgId } = await setupSharedOrg('val-no-goal')
await expectHttpStatus(
user1Api.analytics.fetchSections({
// @ts-expect-error -- intentionally missing goalId
scope: { kind: 'project' },
organizationId: orgId,
period: '30d',
}),
400,
)
})
it('rejects unknown period via raw HTTP', async () => {
const { orgId } = await setupSharedOrg('val-bad-period')
await expectHttpStatus(
axios.get(`${API_URL}/module/analytics/sections`, {
headers: { Authorization: user1AuthHeader },
params: { scope: 'org', organizationId: orgId, period: 'foo' },
}),
400,
)
})
})
describe('Org membership (HTTP 403)', () => {
it('non-member of org cannot access analytics', async () => {
const tsOther = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const otherOrg = await user2Api.organizations.create({ name: `User2 Org ${tsOther}` })
createdOrgIds.push(otherOrg.id)
await expectHttpStatus(
user1Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: otherOrg.id,
period: '30d',
}),
403,
)
})
})
describe('Org owner — sees all org projects', () => {
it('owner gets all org goals in availableGoals (scope=org)', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('owner-org')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
})
const goalIds = result.availableGoals.map(g => g.id).sort()
expect(goalIds).toContain(projectA.id)
expect(goalIds).toContain(projectB.id)
})
it('owner of org with no projects → 200 with empty availableGoals', async () => {
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const emptyOrg = await user1Api.organizations.create({ name: `Empty Org ${ts}` })
createdOrgIds.push(emptyOrg.id)
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: emptyOrg.id,
period: '30d',
})
expect(result.availableGoals).toEqual([])
})
it('owner can fetch project-scoped sections for projectA', async () => {
const { orgId, projectA } = await setupSharedOrg('owner-projA')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
})
expect(result).toBeTruthy()
expect(result.scope).toEqual({ kind: 'project', goalId: projectA.id })
})
it('owner can fetch project-scoped sections for projectB', async () => {
const { orgId, projectB } = await setupSharedOrg('owner-projB')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectB.id },
organizationId: orgId,
period: '30d',
})
expect(result.scope).toEqual({ kind: 'project', goalId: projectB.id })
})
it('respects period filter (smoke for 7d)', async () => {
const { orgId, projectA } = await setupSharedOrg('owner-7d')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '7d',
})
expect(result.period).toBe('7d')
})
it('respects sectionIds filter — returns only requested sections', async () => {
const { orgId, projectA } = await setupSharedOrg('owner-filter')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
sections: ['kpi.overdue'],
})
// Either the section is present, or none are (project can be empty),
// but no other sections than the one requested may appear.
const ids = new Set(result.sections.map(s => s.id))
ids.delete('kpi.overdue')
expect(ids.size).toBe(0)
})
})
describe('Member without analytics_can_view — 403 everywhere', () => {
it('member, scope=org → 403 (no accessible goals)', async () => {
const { orgId } = await setupSharedOrg('member-noperm-org')
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
}),
403,
)
})
it('member, scope=project on owner-only project → 403', async () => {
const { orgId, projectA } = await setupSharedOrg('member-noperm-project')
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
})
describe('Member who created their own project — automatic access', () => {
it('member who is owner of a project sees it in analytics without explicit permission', async () => {
// user1 = owner of org. user2 = member. But here we let an admin create
// the project so they become its goal owner without needing collab.
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const org = await user1Api.organizations.create({ name: `MemberOwner Org ${ts}` })
createdOrgIds.push(org.id)
// Promote user2 to admin so they can create a project
await user1Api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'admin',
})
const ownProject = await user2Api.goals.createGoal({
name: `User2 Owns ${ts}`,
organizationId: org.id,
})
if (!ownProject) throw new Error('Failed to create user2 project')
// user2 owns this project → fetchPermissionsForGoal returns ALL permissions
// for goal owner, including ANALYTICS_CAN_VIEW. So user2 can see analytics
// without an explicit role-based permission grant.
const result = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: ownProject.id },
organizationId: org.id,
period: '30d',
})
expect(result.scope).toEqual({ kind: 'project', goalId: ownProject.id })
const orgScope = await user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: org.id,
period: '30d',
})
const goalIds = orgScope.availableGoals.map(g => g.id)
expect(goalIds).toContain(ownProject.id)
})
})
describe('Member with analytics_can_view on projectA only', () => {
it('member can fetch projectA, but not projectB', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('member-perm-A')
await grantAnalyticsViewToUser2(projectA.id)
const allowed = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
})
expect(allowed.scope).toEqual({ kind: 'project', goalId: projectA.id })
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectB.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
it('member, scope=org → only projectA in availableGoals', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('member-perm-org-scope')
await grantAnalyticsViewToUser2(projectA.id)
const result = await user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
})
const goalIds = result.availableGoals.map(g => g.id)
expect(goalIds).toContain(projectA.id)
expect(goalIds).not.toContain(projectB.id)
})
it('member loses access after being removed from the goal collaboration', async () => {
const { orgId, projectA } = await setupSharedOrg('member-revoke')
await grantAnalyticsViewToUser2(projectA.id)
// Sanity: access works before revoke
const ok = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
})
expect(ok.scope).toEqual({ kind: 'project', goalId: projectA.id })
// Revoke: remove user2 from goal collaboration
const collabUsers = await user1Api.collaboration.fetchUsersForGoal(projectA.id)
const collabUser = collabUsers.find(u => u.email === user2Email)
if (!collabUser) throw new Error('user2 should be a collaborator')
await user1Api.collaboration.deleteUserFromGoal({
id: collabUser.id,
goalId: projectA.id,
})
// Now access should be denied
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
})
describe('Admin treated like member (no automatic org-wide access)', () => {
it('admin without permission → 403 on org scope (no accessible goals)', async () => {
const { orgId } = await setupSharedOrg('admin-noperm')
await user1Api.organizations.updateMemberRole({
organizationId: orgId,
email: user2Email,
role: 'admin',
})
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
}),
403,
)
})
it('admin with permission on projectA only → sees only projectA', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('admin-perm-A')
await user1Api.organizations.updateMemberRole({
organizationId: orgId,
email: user2Email,
role: 'admin',
})
await grantAnalyticsViewToUser2(projectA.id)
const orgResult = await user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
})
const goalIds = orgResult.availableGoals.map(g => g.id)
expect(goalIds).toContain(projectA.id)
expect(goalIds).not.toContain(projectB.id)
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectB.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
})
describe('Drill-down access', () => {
it('drill-down 403 if no permission on the project', async () => {
const { orgId, projectA } = await setupSharedOrg('drill-noperm')
await expectHttpStatus(
user2Api.analytics.fetchDrillDown({
sectionId: 'kpi.overdue',
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
bucket: '',
datasetId: 'kpi',
index: 0,
}),
403,
)
})
it('drill-down 200 with empty tasks if user has permission but no overdue tasks', async () => {
const { orgId, projectA } = await setupSharedOrg('drill-perm')
await grantAnalyticsViewToUser2(projectA.id)
const result = await user2Api.analytics.fetchDrillDown({
sectionId: 'kpi.overdue',
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
bucket: '',
datasetId: 'kpi',
index: 0,
})
expect(result.sectionId).toBe('kpi.overdue')
expect(Array.isArray(result.tasks)).toBe(true)
})
it('drill-down with cross-org goalId via member returns empty (no data leak)', async () => {
const { orgId: orgAId, projectA: projectAOfOrgA } = await setupSharedOrg('drill-cross')
await grantAnalyticsViewToUser2(projectAOfOrgA.id)
// user2 is owner of a separate orgB with a project there
const tsB = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const orgB = await user2Api.organizations.create({ name: `Cross Org B ${tsB}` })
createdOrgIds.push(orgB.id)
const projectInOrgB = await user2Api.goals.createGoal({
name: `Project in B ${tsB}`,
organizationId: orgB.id,
})
if (!projectInOrgB) throw new Error('Failed to create cross-org project')
// user2 tries drill-down for orgB's project but scoped under orgA.
// Middleware passes because user2 owns the project (has all permissions on it).
// The data layer filters by orgA, so the drill-down must return no tasks
// belonging to projectInOrgB. The response itself is 200, but tasks are scoped.
const result = await user2Api.analytics.fetchDrillDown({
sectionId: 'kpi.overdue',
scope: { kind: 'project', goalId: projectInOrgB.id },
organizationId: orgAId,
period: '30d',
bucket: '',
datasetId: 'kpi',
index: 0,
})
// No task from orgB must appear in the response when querying under orgA
const leakedGoalIds = result.tasks.map(t => t.goalId)
expect(leakedGoalIds).not.toContain(projectInOrgB.id)
})
})
describe('Cross-org isolation (no data leakage)', () => {
it('cross-org goalId returns no foreign-org data; sanity that own org data is visible', async () => {
const { orgId: orgAId, projectA: projectAOfOrgA } = await setupSharedOrg('cross-A')
await grantAnalyticsViewToUser2(projectAOfOrgA.id)
const tsB = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const orgB = await user2Api.organizations.create({ name: `Org B ${tsB}` })
createdOrgIds.push(orgB.id)
const projectInOrgB = await user2Api.goals.createGoal({
name: `Project in B ${tsB}`,
organizationId: orgB.id,
})
if (!projectInOrgB) throw new Error('Failed to create project in orgB')
// user2 is member of orgA AND owner of orgB+projectInOrgB.
// They request analytics with organizationId=orgA but goalId from orgB.
// Backend may return 200 (middleware passes because user has permissions
// on the goal directly via ownership), but the data layer filters by
// organizationId, so cross-org data must NOT appear in the response.
const result = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectInOrgB.id },
organizationId: orgAId,
period: '30d',
})
const goalIds = result.availableGoals.map(g => g.id)
// Sanity: orgA data is reachable (user2 has analytics_can_view on projectAOfOrgA)
expect(goalIds).toContain(projectAOfOrgA.id)
// Critical: orgB data must NOT leak through orgA-scoped request
expect(goalIds).not.toContain(projectInOrgB.id)
})
})
})
@@ -2,6 +2,7 @@ services:
db:
image: postgres:17
restart: "no"
command: postgres -c max_connections=300
env_file:
- .env.postgresql
ports:
@@ -47,6 +47,7 @@ async function cleanDatabase() {
const tables = [
'tv_auth.api_tokens',
'tv_auth.user_tokens',
'history.tasks_tasks',
'collaboration.users',
'tasks.goals',
@@ -23,6 +23,7 @@ async function cleanDatabase() {
const tables = [
'tv_auth.api_tokens',
'tv_auth.user_tokens',
'history.tasks_tasks',
'collaboration.users',
'tasks.goals',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type {
AnalyticsFetchDrillDownArg,
AnalyticsDrillDownResponse,
AnalyticsFetchSectionsArg,
AnalyticsSectionsResponse,
AnalyticsScope,
} from './analytics.types'
function scopeToParams(scope: AnalyticsScope): Record<string, string> {
switch (scope.kind) {
case 'org':
return { scope: 'org' }
case 'project':
return { scope: 'project', goalId: String(scope.goalId) }
}
}
export default class TvAnalyticsApi extends TvApiBase {
protected moduleUrl = '/module/analytics'
public async fetchSections(arg: AnalyticsFetchSectionsArg, signal?: AbortSignal) {
const params: Record<string, string> = {
...scopeToParams(arg.scope),
organizationId: String(arg.organizationId),
period: arg.period,
}
if (arg.from) params.from = arg.from
if (arg.to) params.to = arg.to
if (arg.sections?.length) params.sections = arg.sections.join(',')
return this.request(
this.$axios.get<AppResponse<AnalyticsSectionsResponse>>(
`${this.moduleUrl}/sections`,
{ params, signal },
),
)
}
public async fetchDrillDown(arg: AnalyticsFetchDrillDownArg, signal?: AbortSignal) {
const params: Record<string, string> = {
...scopeToParams(arg.scope),
organizationId: String(arg.organizationId),
period: arg.period,
}
if (arg.from) params.from = arg.from
if (arg.to) params.to = arg.to
if (arg.bucket) params.bucket = arg.bucket
if (arg.datasetId) params.datasetId = arg.datasetId
if (arg.index !== undefined) params.index = String(arg.index)
if (arg.meta) params.meta = JSON.stringify(arg.meta)
return this.request(
this.$axios.get<AppResponse<AnalyticsDrillDownResponse>>(
`${this.moduleUrl}/drilldown/${arg.sectionId}`,
{ params, signal },
),
)
}
}
@@ -0,0 +1,167 @@
export type AnalyticsChartType =
| 'line'
| 'bar'
| 'area'
| 'stackedBar'
| 'stackedArea'
| 'horizontalBar'
| 'donut'
| 'histogram'
| 'radar'
export type AnalyticsScope =
| { kind: 'org' }
| { kind: 'project'; goalId: number }
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'
export type AnalyticsRange = {
from: string
to: string
}
export type LocalizedText = {
ru: string
en: string
}
export type AnalyticsUnit =
| 'count'
| 'days'
| 'hours'
| 'percent'
| 'currency'
export type AnalyticsColorToken =
| 'primary'
| 'success'
| 'warning'
| 'danger'
| 'neutral'
| 'info'
export type AnalyticsDataset = {
id: string
label: LocalizedText
values: (number | null)[]
colorToken?: AnalyticsColorToken
stack?: string
meta?: Record<string, unknown>
}
export type AnalyticsReferenceLine = {
id: string
label: LocalizedText
value: number
axis: 'x' | 'y'
colorToken?: AnalyticsColorToken
}
export type AnalyticsSeriesPayload = {
kind: 'series'
labels: string[]
labelTexts?: LocalizedText[]
labelKind: 'date' | 'category'
datasets: AnalyticsDataset[]
referenceLines?: AnalyticsReferenceLine[]
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
unit: AnalyticsUnit
}
export type AnalyticsKpiDelta = {
value: number
direction: 'up' | 'down' | 'flat'
isGood: boolean
}
export type AnalyticsKpiPayload = {
kind: 'kpi'
value: number
delta?: AnalyticsKpiDelta
unit: AnalyticsUnit
sparkline?: number[]
}
export type AnalyticsSectionGroup =
| 'kpi'
| 'productivity'
| 'quality'
| 'workload'
| 'financial'
| 'usage'
export type AnalyticsDrillDownKind = 'tasks' | 'users' | 'projects'
export type AnalyticsSectionHelp = {
summary: LocalizedText
details: LocalizedText
}
export type AnalyticsSection = {
id: string
title: LocalizedText
description?: LocalizedText
help?: AnalyticsSectionHelp
group: AnalyticsSectionGroup
allowedChartTypes: AnalyticsChartType[]
defaultChartType: AnalyticsChartType | null
payload: AnalyticsSeriesPayload | AnalyticsKpiPayload
drillDown?: { kind: AnalyticsDrillDownKind }
generatedAt: string
}
export type AnalyticsAvailableGoal = {
id: number
name: string
}
export type AnalyticsSectionsResponse = {
scope: AnalyticsScope
period: AnalyticsPeriod
range: AnalyticsRange
sections: AnalyticsSection[]
availableGoals: AnalyticsAvailableGoal[]
failedSectionIds: string[]
}
export type AnalyticsFetchSectionsArg = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
from?: string
to?: string
sections?: string[]
}
export type AnalyticsDrillDownTask = {
id: number
description: string
goalId: number
goalName: string
complete: boolean
priorityId: number | null
endDate: string | null
date_creation: string
date_complete: string | null
}
export type AnalyticsDrillDownResponse = {
sectionId: string
tasks: AnalyticsDrillDownTask[]
total: number
denied?: boolean
}
export type AnalyticsFetchDrillDownArg = {
sectionId: string
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
from?: string
to?: string
bucket?: string
index?: number
datasetId?: string
meta?: Record<string, unknown>
}
@@ -125,6 +125,28 @@ export const TvPermissions: Record<Uppercase<keyof GoalPermissions>, keyof GoalP
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
/**
* Can view analytics dashboards and KPIs for this goal
*/
ANALYTICS_CAN_VIEW: 'analytics_can_view',
/**
* View all time entries on this project — both own and other members'.
* Does not grant logging or editing.
*/
TIMETRACKING_CAN_VIEW: 'timetracking_can_view',
/**
* Start/stop timer and create/edit/delete OWN time entries on this project.
* Does not grant viewing the project log or managing others' entries.
*/
TIMETRACKING_CAN_LOG: 'timetracking_can_log',
/**
* Full time-tracking access on this project: log own time, view all entries
* (own and other members'), and edit/delete entries of any project member.
* Implies both view and log permissions.
*/
TIMETRACKING_CAN_MANAGE_ALL: 'timetracking_can_manage_all',
} as const;
export type GoalPermissions = {
@@ -167,4 +189,10 @@ export type GoalPermissions = {
integrations_can_manage?: true;
integrations_can_view?: true;
analytics_can_view?: true;
timetracking_can_view?: true;
timetracking_can_log?: true;
timetracking_can_manage_all?: true;
};
@@ -0,0 +1,133 @@
import TvApiBase from './base'
import type { AppResponse } from '@/api/base.types'
import type {
TimeEntryCreateManualArg,
TimeEntryFetchFilters,
TimeEntryHistoryItem,
TimeEntryItem,
TimeEntryStartArg,
TimeEntryStartResult,
TimeEntryStopArg,
TimeEntrySummaryByGoal,
TimeEntrySummaryByTask,
TimeEntryUpdateArg,
TimeReportByDayRow,
TimeReportByTaskRow,
TimeReportByUserRow,
TimeReportContributor,
TimeReportFilters,
TimeReportSummary,
} from './time-tracking.types'
export default class TvTimeTrackingApi extends TvApiBase {
protected moduleUrl = '/module/time-tracking'
private static reportParams(filters: TimeReportFilters) {
const params: Record<string, string | number | boolean> = {
organizationId: filters.organizationId,
from: filters.from instanceof Date ? filters.from.toISOString() : filters.from,
to: filters.to instanceof Date ? filters.to.toISOString() : filters.to,
}
if (filters.goalIds && filters.goalIds.length > 0) {
params.goalIds = filters.goalIds.join(',')
}
if (filters.userId !== undefined) params.userId = filters.userId
if (filters.billable !== undefined) params.billable = filters.billable
if (filters.timezone !== undefined) params.timezone = filters.timezone
return params
}
public async start(data: TimeEntryStartArg) {
return this.request(this.$axios.post<AppResponse<TimeEntryStartResult>>(`${this.moduleUrl}/start`, data))
}
public async stop(data: TimeEntryStopArg = {}) {
return this.request(this.$axios.post<AppResponse<TimeEntryItem>>(`${this.moduleUrl}/stop`, data))
}
public async getActive() {
return this.request(this.$axios.get<AppResponse<TimeEntryItem | null>>(`${this.moduleUrl}/active`))
}
public async createManual(data: TimeEntryCreateManualArg) {
return this.request(this.$axios.post<AppResponse<TimeEntryItem>>(`${this.moduleUrl}/entries`, data))
}
public async update(data: TimeEntryUpdateArg) {
const { id, ...rest } = data
return this.request(this.$axios.patch<AppResponse<TimeEntryItem>>(`${this.moduleUrl}/entries/${id}`, rest))
}
public async delete(id: number) {
return this.request(
this.$axios.delete<AppResponse<{ deleted: boolean }>>(`${this.moduleUrl}/entries/${id}`),
)
}
public async fetchEntries(filters: TimeEntryFetchFilters = {}) {
const { goalIds, ...rest } = filters
const params: Record<string, unknown> = { ...rest }
if (goalIds && goalIds.length > 0) params.goalIds = goalIds.join(',')
return this.request(
this.$axios.get<AppResponse<TimeEntryItem[]>>(`${this.moduleUrl}/entries`, { params }),
)
}
public async summaryByTask(taskId: number) {
return this.request(
this.$axios.get<AppResponse<TimeEntrySummaryByTask>>(`${this.moduleUrl}/summary/task/${taskId}`),
)
}
public async summaryByGoal(goalId: number) {
return this.request(
this.$axios.get<AppResponse<TimeEntrySummaryByGoal>>(`${this.moduleUrl}/summary/goal/${goalId}`),
)
}
public async fetchHistory(entryId: number) {
return this.request(
this.$axios.get<AppResponse<TimeEntryHistoryItem[]>>(`${this.moduleUrl}/entries/${entryId}/history`),
)
}
public async reportSummary(filters: TimeReportFilters) {
return this.request(
this.$axios.get<AppResponse<TimeReportSummary>>(`${this.moduleUrl}/reports/summary`, {
params: TvTimeTrackingApi.reportParams(filters),
}),
)
}
public async reportByDay(filters: TimeReportFilters) {
return this.request(
this.$axios.get<AppResponse<TimeReportByDayRow[]>>(`${this.moduleUrl}/reports/by-day`, {
params: TvTimeTrackingApi.reportParams(filters),
}),
)
}
public async reportByUser(filters: TimeReportFilters) {
return this.request(
this.$axios.get<AppResponse<TimeReportByUserRow[]>>(`${this.moduleUrl}/reports/by-user`, {
params: TvTimeTrackingApi.reportParams(filters),
}),
)
}
public async reportByTask(filters: TimeReportFilters) {
return this.request(
this.$axios.get<AppResponse<TimeReportByTaskRow[]>>(`${this.moduleUrl}/reports/by-task`, {
params: TvTimeTrackingApi.reportParams(filters),
}),
)
}
public async reportContributors(filters: TimeReportFilters) {
return this.request(
this.$axios.get<AppResponse<TimeReportContributor[]>>(`${this.moduleUrl}/reports/contributors`, {
params: TvTimeTrackingApi.reportParams(filters),
}),
)
}
}
@@ -0,0 +1,126 @@
export type TimeEntryItem = {
id: number
taskId: number
goalId: number
userId: number
userEmail: string | null
startedAt: string
endedAt: string | null
durationSeconds: number | null
description: string | null
source: 0 | 1
billable: boolean
autoStopped: boolean
createdAt: string
editedAt: string
}
export type TimeEntryStartArg = {
taskId: number
description?: string
}
export type TimeEntryStartResult = {
entry: TimeEntryItem
autoStoppedEntry: TimeEntryItem | null
}
export type TimeEntryStopArg = {
entryId?: number
}
export type TimeEntryCreateManualArg = {
taskId: number
startedAt: string | Date
endedAt: string | Date
description?: string
billable?: boolean
}
export type TimeEntryUpdateArg = {
id: number
startedAt?: string | Date
endedAt?: string | Date
description?: string
billable?: boolean
}
export type TimeEntryFetchFilters = {
organizationId?: number
goalId?: number
goalIds?: number[]
taskId?: number
userId?: number
billable?: boolean
from?: string | Date
to?: string | Date
limit?: number
offset?: number
}
export type TimeEntrySummaryByTask = {
totalSeconds: number
byUser: { userId: number; seconds: number }[]
}
export type TimeEntrySummaryByGoal = {
totalSeconds: number
byUser: { userId: number; seconds: number }[]
byTask: { taskId: number; seconds: number }[]
}
export type TimeEntryHistoryItem = {
id: number
entryId: number
editDate: string
entry: TimeEntryItem
}
export const TIME_ENTRY_SOURCE = {
TIMER: 0,
MANUAL: 1,
} as const
export type TimeReportFilters = {
organizationId: number
goalIds?: number[]
userId?: number
from: string | Date
to: string | Date
billable?: boolean
timezone?: string
}
export type TimeReportByDayRow = {
day: string
totalSeconds: number
entriesCount: number
}
export type TimeReportByUserRow = {
userId: number
userEmail: string | null
totalSeconds: number
entriesCount: number
}
export type TimeReportByTaskRow = {
taskId: number
taskDescription: string | null
goalId: number
totalSeconds: number
entriesCount: number
}
export type TimeReportSummary = {
totalSeconds: number
totalBillableSeconds: number
entriesCount: number
}
export type TimeReportContributor = {
userId: number
userEmail: string | null
totalSeconds: number
entriesCount: number
}
@@ -47,6 +47,11 @@ export const WEBHOOK_EVENTS = [
'task.updated',
'task.deleted',
'task.assigneesChanged',
'time-entry.started',
'time-entry.stopped',
'time-entry.created',
'time-entry.updated',
'time-entry.deleted',
] as const;
export type WebhookEvent = typeof WEBHOOK_EVENTS[number];
+3 -1
View File
@@ -15,4 +15,6 @@ export * from '@/api/webhooks.types';
export * from '@/api/api-tokens.types';
export * from '@/api/sessions.types';
export * from '@/api/organizations.types';
export * from '@/api/sso.types';
export * from '@/api/sso.types';
export * from '@/api/analytics.types';
export * from '@/api/time-tracking.types';
+10
View File
@@ -13,6 +13,8 @@ import TvApiTokens from "./api/api-tokens";
import TvSessions from "./api/sessions";
import TvOrganizationsApi from "./api/organizations";
import TvSsoApi from "./api/sso";
import TvAnalyticsApi from "./api/analytics";
import TvTimeTrackingApi from "./api/time-tracking";
export class TvApi {
@@ -46,6 +48,10 @@ export class TvApi {
public sso: TvSsoApi;
public analytics: TvAnalyticsApi;
public timeTracking: TvTimeTrackingApi;
constructor($axios: AxiosInstance) {
this.$axios = $axios;
@@ -76,6 +82,10 @@ export class TvApi {
this.organizations = new TvOrganizationsApi(this.$axios);
this.sso = new TvSsoApi(this.$axios);
this.analytics = new TvAnalyticsApi(this.$axios);
this.timeTracking = new TvTimeTrackingApi(this.$axios);
}
public setBaseUrl(baseUrl: string) {
@@ -17,3 +17,5 @@ export * from './schemas/api-tokens.schema';
export * from './schemas/user-tokens.schema';
export * from './schemas/organizations.schema';
export * from './schemas/sso.schema';
export * from './schemas/time-entries.schema';
export * from './schemas/time-entries-history.schema';
@@ -9,6 +9,7 @@ export const OrganizationsSchema = pgSchema('tv_auth').table('organizations', {
logoUrl: varchar('logo_url'),
isPersonal: integer('is_personal').notNull().default(0),
plan: varchar().notNull().default('free'),
timeTrackingAutostopHours: integer('time_tracking_autostop_hours').default(24),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
})
@@ -0,0 +1,12 @@
import { integer, jsonb, pgSchema, timestamp } from 'drizzle-orm/pg-core'
import { TimeEntriesSchema } from './time-entries.schema'
export const TimeEntriesHistorySchema = pgSchema('history').table('time_entries', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
entryId: integer('entry_id').notNull().references(() => TimeEntriesSchema.id, { onDelete: 'cascade' }),
editDate: timestamp('edit_date').notNull().defaultNow(),
entry: jsonb().notNull(),
})
export type TimeEntriesHistorySchemaTypeForSelect = typeof TimeEntriesHistorySchema.$inferSelect
export type TimeEntriesHistorySchemaTypeForInsert = typeof TimeEntriesHistorySchema.$inferInsert
@@ -0,0 +1,26 @@
import { boolean, integer, pgSchema, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
import { createInsertSchema } from 'drizzle-arktype'
import { TasksSchema } from './tasks.schema'
import { GoalsSchema } from './goals.schema'
import { UsersSchema } from './users.schema'
export const TimeEntriesSchema = pgSchema('tasks').table('time_entries', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
taskId: integer('task_id').notNull().references(() => TasksSchema.id, { onDelete: 'cascade' }),
goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
userId: integer('user_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
startedAt: timestamp('started_at').notNull().defaultNow(),
endedAt: timestamp('ended_at'),
durationSeconds: integer('duration_seconds'),
description: varchar({ length: 500 }),
source: smallint().$type<0 | 1>().notNull().default(0),
billable: boolean().notNull().default(true),
autoStopped: boolean('auto_stopped').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
editedAt: timestamp('edited_at').notNull().defaultNow(),
})
export type TimeEntriesSchemaTypeForSelect = typeof TimeEntriesSchema.$inferSelect
export type TimeEntriesSchemaTypeForInsert = typeof TimeEntriesSchema.$inferInsert
export const TimeEntriesSchemaArkTypeInsert = createInsertSchema(TimeEntriesSchema)
+2 -1
View File
@@ -1,2 +1,3 @@
export TASKVIEW_URL=http://127.0.0.1:11401
export TASKVIEW_TOKEN=tvk_a05851cc02d629164838ff5adacab68c440b7f68f77f5c5fd0171f8256d7ee30
export TASKVIEW_LOGIN=user
export TASKVIEW_PASSWORD=user1!#Q
@@ -72,4 +72,70 @@ describe('collaboration integration', () => {
expect(user.email).toBe(email)
})
it('removes a collaborator', async () => {
const email = `remove-${ts()}@integration-test.com`
const invited = parse(await call(tools, 'invite_collaborator', { goalId, email }))
const result = await call(tools, 'remove_collaborator', { goalId, id: invited.id })
const data = parse(result)
expect(data.deleted).toBe(true)
const users = parse(await call(tools, 'list_collaborators_for_goal', { goalId })) as Array<{ email: string }>
expect(users.find((u) => u.email === email)).toBeUndefined()
})
it('toggles collaborator roles', async () => {
const email = `roles-${ts()}@integration-test.com`
const invited = parse(await call(tools, 'invite_collaborator', { goalId, email }))
const roles = parse(await call(tools, 'list_roles', { goalId })) as Array<{ id: number; name: string }>
const editor = roles.find((r) => r.name === 'editor')
expect(editor).toBeDefined()
const result = await call(tools, 'toggle_collaborator_roles', {
goalId,
userId: invited.id,
roles: [editor!.id],
})
const data = parse(result)
expect(Array.isArray(data.roles)).toBe(true)
expect(data.roles).toContain(editor!.id)
const users = parse(await call(tools, 'list_collaborators_for_goal', { goalId })) as Array<{ id: number; roles: number[] }>
const user = users.find((u) => u.id === invited.id)
expect(user?.roles).toContain(editor!.id)
})
it('deletes a role', async () => {
const created = parse(await call(tools, 'create_role', { goalId, roleName: `delete-me-${ts()}` }))
const result = await call(tools, 'delete_role', { goalId, id: created.id })
const data = parse(result)
expect(data.deleted).toBe(true)
const roles = parse(await call(tools, 'list_roles', { goalId })) as Array<{ id: number }>
expect(roles.find((r) => r.id === created.id)).toBeUndefined()
})
it('toggles role permission (add and remove)', async () => {
const role = parse(await call(tools, 'create_role', { goalId, roleName: `perm-role-${ts()}` }))
const permissions = parse(await call(tools, 'list_permissions')) as Array<{ id: number; name: string }>
const targetPerm = permissions.find((p) => p.name === 'goal_can_watch_content')
expect(targetPerm).toBeDefined()
const addResult = parse(await call(tools, 'toggle_role_permission', {
roleId: role.id,
permissionId: targetPerm!.id,
}))
expect(addResult.add).toBe(true)
const removeResult = parse(await call(tools, 'toggle_role_permission', {
roleId: role.id,
permissionId: targetPerm!.id,
}))
expect(removeResult.add).toBe(false)
await call(tools, 'delete_role', { goalId, id: role.id }).catch(() => {})
})
})

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