diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 15d59ff..bca640b 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -538,5 +538,16 @@ "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": "20260514", + "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" + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.50.0/0.update-timetracking-permissions-descriptions.sql b/api/src/migrations/taskview/sql/1.50.0/0.update-timetracking-permissions-descriptions.sql new file mode 100644 index 0000000..0be775a --- /dev/null +++ b/api/src/migrations/taskview/sql/1.50.0/0.update-timetracking-permissions-descriptions.sql @@ -0,0 +1,11 @@ +UPDATE tv_auth.permissions +SET + description = 'View all time entries on this project — both own and other members''. Also exposes the emails of all contributors via the time-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 = 'Full time-tracking access: log own time + view and edit/delete entries of any project member. Also exposes emails of all contributors', + 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. 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'; diff --git a/api/src/tv-modules/time-tracking/types.ts b/api/src/tv-modules/time-tracking/types.ts index 9eca29f..d2d09df 100644 --- a/api/src/tv-modules/time-tracking/types.ts +++ b/api/src/tv-modules/time-tracking/types.ts @@ -107,6 +107,8 @@ const Timezone = type('string').narrow((v, ctx) => { } }) +const MAX_REPORT_WINDOW_MS = 2 * 365 * 24 * 60 * 60 * 1000 + export const TimeReportArkTypeFilters = type({ 'goalIds?': NumberListFromString, 'userId?': OptionalNumberFromString, @@ -114,6 +116,19 @@ export const TimeReportArkTypeFilters = type({ 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 diff --git a/taskview-packages/taskview-api/src/api/__tests__/time-tracking.test.ts b/taskview-packages/taskview-api/src/api/__tests__/time-tracking.test.ts index ffa92d2..ccf5d2d 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/time-tracking.test.ts +++ b/taskview-packages/taskview-api/src/api/__tests__/time-tracking.test.ts @@ -963,6 +963,65 @@ describe('TvApi time-tracking tests', () => { expect(status).toBe(400); }); + it('reportSummary with from >= to is rejected (400)', async () => { + const status = await $mainUser.timeTracking.reportSummary({ + organizationId, + from: isoMinutesFromNow(60), + to: isoMinutesAgo(60), + }).catch((err) => err.status); + expect(status).toBe(400); + }); + + it('reportSummary with from == to is rejected (400)', async () => { + const sameMoment = new Date().toISOString(); + const status = await $mainUser.timeTracking.reportSummary({ + organizationId, + from: sameMoment, + to: sameMoment, + }).catch((err) => err.status); + expect(status).toBe(400); + }); + + it('reportSummary with date range wider than 2 years is rejected (400)', async () => { + const status = await $mainUser.timeTracking.reportSummary({ + organizationId, + from: '2000-01-01T00:00:00Z', + to: '2099-12-31T23:59:59Z', + }).catch((err) => err.status); + expect(status).toBe(400); + }); + + it('reportByDay with date range wider than 2 years is rejected (400)', async () => { + const status = await $mainUser.timeTracking.reportByDay({ + organizationId, + from: '2000-01-01T00:00:00Z', + to: '2099-12-31T23:59:59Z', + }).catch((err) => err.status); + expect(status).toBe(400); + }); + + it('reportContributors with from >= to is rejected (400)', async () => { + const status = await $mainUser.timeTracking.reportContributors({ + organizationId, + from: isoMinutesFromNow(60), + to: isoMinutesAgo(60), + }).catch((err) => err.status); + expect(status).toBe(400); + }); + + it('report with exactly 2-year window is accepted', async () => { + const twoYearsAgo = new Date() + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2) + twoYearsAgo.setHours(twoYearsAgo.getHours() + 1) + const result = await $mainUser.timeTracking.reportSummary({ + organizationId, + from: twoYearsAgo.toISOString(), + to: new Date().toISOString(), + }).catch((err) => err.status); + expect(typeof result).toBe('object'); + expect((result as { totalSeconds: number }).totalSeconds).toBeGreaterThanOrEqual(0); + }); + it('deleting an active timer removes it and clears active state', async () => { const started = await $mainUser.timeTracking.start({ taskId }).catch(console.error); expect(started?.entry).toBeDefined(); diff --git a/taskview-packages/taskview-mcp/src/__tests__/integration/time-tracking.integration.test.ts b/taskview-packages/taskview-mcp/src/__tests__/integration/time-tracking.integration.test.ts index f9bd95c..9939a7b 100644 --- a/taskview-packages/taskview-mcp/src/__tests__/integration/time-tracking.integration.test.ts +++ b/taskview-packages/taskview-mcp/src/__tests__/integration/time-tracking.integration.test.ts @@ -245,6 +245,26 @@ describe('time-tracking integration', () => { expect(result.isError).toBe(true) }) + it('rejects report with from >= to', async () => { + const result = await call(tools, 'get_time_report', { + scope: 'summary', + organizationId, + from: iso(60), + to: iso(-60), + }) + expect(result.isError).toBe(true) + }) + + it('rejects report with date range wider than 2 years', async () => { + const result = await call(tools, 'get_time_report', { + scope: 'summary', + organizationId, + from: '2000-01-01T00:00:00Z', + to: '2099-12-31T23:59:59Z', + }) + expect(result.isError).toBe(true) + }) + it('deletes a time entry', async () => { const created = await call(tools, 'log_time', { taskId,