mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff539f00f5 | |||
| eede789d54 | |||
| c532f607aa | |||
| 791b9d1c6e | |||
| da82f3ae0f | |||
| 96c40d5f49 | |||
| dbd5253423 | |||
| c0c0a49c0d | |||
| f0fed9d68b | |||
| ee67342313 | |||
| a4bb9de09f | |||
| 0d01b4d532 | |||
| a5446ea825 | |||
| ec50b31642 | |||
| 1706a3124b | |||
| 6cf26fdd61 | |||
| d081436519 | |||
| 40e54966a8 | |||
| 4527ae6c4b | |||
| 9df7b4a7cd | |||
| 5afcc2d8ec | |||
| d2c9bee0d3 | |||
| c3ff29501b | |||
| f791822c79 | |||
| 344526b1b2 | |||
| 95f024275c | |||
| fd8ea2e328 |
@@ -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)
|
||||
[]()
|
||||
|
||||
[**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.
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
+17
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 1–3 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: '1–3 дн', en: '1–3 d' },
|
||||
bucket_4_7: { ru: '4–7 дн', en: '4–7 d' },
|
||||
bucket_8_14: { ru: '8–14 дн', en: '8–14 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 2–3× 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д', '1–3д', '3–7д', '7–14д', '14–30д', '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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+46
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+66
@@ -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
Reference in New Issue
Block a user