diff --git a/api/src/core/EventBus.ts b/api/src/core/EventBus.ts index c7ff0bc..b3138a5 100644 --- a/api/src/core/EventBus.ts +++ b/api/src/core/EventBus.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events'; -import type { TasksSchemaTypeForSelect, TimeEntriesSchemaTypeForSelect } from 'taskview-db-schemas'; +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,10 +11,10 @@ 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: TimeEntriesSchemaTypeForSelect; taskId: number; userId: number; goalId: number }; - 'time-entry.stopped': { entry: TimeEntriesSchemaTypeForSelect; taskId: number; userId: number; goalId: number; durationSeconds: number }; - 'time-entry.created': { entry: TimeEntriesSchemaTypeForSelect; initiatorId: number }; - 'time-entry.updated': { entry: TimeEntriesSchemaTypeForSelect; changes: Record; 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; initiatorId: number }; 'time-entry.deleted': { entryId: number; taskId: number; goalId: number; userId: number; initiatorId: number }; } diff --git a/api/src/migrations/taskview/sql/1.48.0/3.add-timetracking-permissions.sql b/api/src/migrations/taskview/sql/1.48.0/3.add-timetracking-permissions.sql index 85287f6..49847dd 100644 --- a/api/src/migrations/taskview/sql/1.48.0/3.add-timetracking-permissions.sql +++ b/api/src/migrations/taskview/sql/1.48.0/3.add-timetracking-permissions.sql @@ -14,8 +14,8 @@ VALUES ), ( 'timetracking_can_manage_all', - 'Edit/delete time entries of other project members (implies view)', + 'Full time-tracking access: log own time + view and edit/delete entries of any project member', 2, - '{"en": "Manage all time entries. User can edit/delete time entries of any project member. Includes viewing the full project log.", "ru": "Управление чужими записями времени. Пользователь может редактировать/удалять записи любого участника проекта. Включает просмотр всех записей проекта."}'::jsonb + '{"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; diff --git a/api/src/tv-modules/time-tracking/TimeTrackingManager.ts b/api/src/tv-modules/time-tracking/TimeTrackingManager.ts index 11313a6..eef1988 100644 --- a/api/src/tv-modules/time-tracking/TimeTrackingManager.ts +++ b/api/src/tv-modules/time-tracking/TimeTrackingManager.ts @@ -1,4 +1,3 @@ -import type { TimeEntriesSchemaTypeForSelect } from 'taskview-db-schemas' import type { AppUser } from '../../core/AppUser' import { eventBus } from '../../core/EventBus' import { TimeTrackingRepository } from './TimeTrackingRepository' @@ -11,6 +10,7 @@ import { type TimeEntryArgUpdate, type TimeEntryStartResult, type TimeEntryUpdateParams, + type TimeEntryWithUser, } from './types' export class TimeTrackingManager { @@ -30,7 +30,7 @@ export class TimeTrackingManager { return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000)) } - async getActive(): Promise { + async getActive(): Promise { const userId = this.getCurrentUserId() if (!userId) return null return this.repository.findActiveForUser(userId) @@ -71,7 +71,7 @@ export class TimeTrackingManager { return null } - private async closeActiveTimer(userId: number): Promise { + private async closeActiveTimer(userId: number): Promise { const active = await this.repository.findActiveForUser(userId) if (!active) return null @@ -93,7 +93,7 @@ export class TimeTrackingManager { return stopped } - async stop(data: TimeEntryArgStop): Promise { + async stop(data: TimeEntryArgStop): Promise { const userId = this.getCurrentUserId() if (!userId) return null @@ -121,7 +121,7 @@ export class TimeTrackingManager { return stopped } - async createManual(data: TimeEntryArgCreate): Promise { + async createManual(data: TimeEntryArgCreate): Promise { const userId = this.getCurrentUserId() if (!userId) return null @@ -147,7 +147,7 @@ export class TimeTrackingManager { return entry } - async update(data: TimeEntryArgUpdate): Promise { + async update(data: TimeEntryArgUpdate): Promise { const userId = this.getCurrentUserId() if (!userId) return null @@ -210,7 +210,7 @@ export class TimeTrackingManager { return true } - async fetchEntries(filters: TimeEntryArgFetchEntries): Promise { + async fetchEntries(filters: TimeEntryArgFetchEntries): Promise { const userId = this.getCurrentUserId() if (!userId) return [] @@ -221,10 +221,13 @@ export class TimeTrackingManager { goalId = task.goalId } + const hasProjectScope = filters.goalId !== undefined || filters.taskId !== undefined + const effectiveUserId = hasProjectScope ? filters.userId : (filters.userId ?? userId) + return this.repository.fetchEntries({ goalId, taskId: filters.taskId, - userId: filters.userId ?? userId, + userId: effectiveUserId, from: filters.from, to: filters.to, limit: filters.limit, diff --git a/api/src/tv-modules/time-tracking/TimeTrackingRepository.ts b/api/src/tv-modules/time-tracking/TimeTrackingRepository.ts index 30e8231..c5460e5 100644 --- a/api/src/tv-modules/time-tracking/TimeTrackingRepository.ts +++ b/api/src/tv-modules/time-tracking/TimeTrackingRepository.ts @@ -1,27 +1,46 @@ -import { and, desc, eq, gte, isNull, lte, sql, type SQL } from 'drizzle-orm' +import { and, desc, eq, gte, inArray, isNull, lte, sql, type SQL } from 'drizzle-orm' import { TimeEntriesSchema, TimeEntriesHistorySchema, TasksSchema, - type TimeEntriesSchemaTypeForSelect, + UsersSchema, type TimeEntriesHistorySchemaTypeForSelect, } from 'taskview-db-schemas' import { Database } from '../../modules/db' import { $logger } from '../../modules/logget' import { callWithCatch } from '../../utils/helpers' - -const PG_UNIQUE_VIOLATION = '23505' import type { TimeEntryFilters, TimeEntryGoalSummary, TimeEntryInsertParams, + TimeEntryInsertResult, TimeEntryTaskSummary, TimeEntryUpdateParams, + TimeEntryWithUser, } 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() } @@ -36,24 +55,41 @@ export class TimeTrackingRepository { return result?.[0] ?? null } - async findActiveForUser(userId: number): Promise { + async findActiveForUser(userId: number): Promise { const result = await callWithCatch(() => this.db.dbDrizzle - .select() + .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 findById(id: number): Promise { + async findById(id: number): Promise { const result = await callWithCatch(() => - this.db.dbDrizzle.select().from(TimeEntriesSchema).where(eq(TimeEntriesSchema.id, id)), + 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 insert(data: TimeEntryInsertParams): Promise { + async findByIds(ids: number[]): Promise { + 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 { const result = await callWithCatch(() => this.db.dbDrizzle .insert(TimeEntriesSchema) @@ -68,14 +104,14 @@ export class TimeTrackingRepository { source: data.source, billable: data.billable ?? true, }) - .returning(), + .returning({ id: TimeEntriesSchema.id }), ) - return result?.[0] ?? null + const id = result?.[0]?.id + if (!id) return null + return this.findById(id) } - async tryInsertActiveTimer( - data: TimeEntryInsertParams, - ): Promise<{ entry: TimeEntriesSchemaTypeForSelect | null; conflict: boolean }> { + async tryInsertActiveTimer(data: TimeEntryInsertParams): Promise { try { const result = await this.db.dbDrizzle .insert(TimeEntriesSchema) @@ -90,8 +126,11 @@ export class TimeTrackingRepository { source: data.source, billable: data.billable ?? true, }) - .returning() - return { entry: result?.[0] ?? null, conflict: false } + .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) { @@ -102,15 +141,17 @@ export class TimeTrackingRepository { } } - async updateById(id: number, data: TimeEntryUpdateParams): Promise { + async updateById(id: number, data: TimeEntryUpdateParams): Promise { const result = await callWithCatch(() => this.db.dbDrizzle .update(TimeEntriesSchema) .set(data) .where(eq(TimeEntriesSchema.id, id)) - .returning(), + .returning({ id: TimeEntriesSchema.id }), ) - return result?.[0] ?? null + const updatedId = result?.[0]?.id + if (!updatedId) return null + return this.findById(updatedId) } async deleteById(id: number): Promise { @@ -120,7 +161,7 @@ export class TimeTrackingRepository { return !!result?.rowCount } - async fetchEntries(filters: TimeEntryFilters): Promise { + async fetchEntries(filters: TimeEntryFilters): Promise { const conditions: SQL[] = [] if (filters.goalId !== undefined) conditions.push(eq(TimeEntriesSchema.goalId, filters.goalId)) if (filters.taskId !== undefined) conditions.push(eq(TimeEntriesSchema.taskId, filters.taskId)) @@ -133,8 +174,9 @@ export class TimeTrackingRepository { const result = await callWithCatch(() => this.db.dbDrizzle - .select() + .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) @@ -216,8 +258,8 @@ export class TimeTrackingRepository { return result ?? [] } - async autoStopOverdue(): Promise { - const result = await callWithCatch(() => + async autoStopOverdue(): Promise { + const updated = await callWithCatch(() => this.db.dbDrizzle .update(TimeEntriesSchema) .set({ @@ -239,8 +281,9 @@ export class TimeTrackingRepository { )`, ), ) - .returning(), + .returning({ id: TimeEntriesSchema.id }), ) - return result ?? [] + const ids = (updated ?? []).map((r) => r.id) + return this.findByIds(ids) } } diff --git a/api/src/tv-modules/time-tracking/types.ts b/api/src/tv-modules/time-tracking/types.ts index 27360e5..2f98cfd 100644 --- a/api/src/tv-modules/time-tracking/types.ts +++ b/api/src/tv-modules/time-tracking/types.ts @@ -1,6 +1,10 @@ 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)) @@ -111,8 +115,13 @@ export type TimeEntryFilters = { } export type TimeEntryStartResult = { - entry: TimeEntriesSchemaTypeForSelect - autoStoppedEntry: TimeEntriesSchemaTypeForSelect | null + entry: TimeEntryWithUser + autoStoppedEntry: TimeEntryWithUser | null +} + +export type TimeEntryInsertResult = { + entry: TimeEntryWithUser | null + conflict: boolean } export type TimeEntryUserSeconds = { userId: number; seconds: number } diff --git a/taskview-packages/taskview-api/src/api/permissions.ts b/taskview-packages/taskview-api/src/api/permissions.ts index 1620a58..2481e34 100644 --- a/taskview-packages/taskview-api/src/api/permissions.ts +++ b/taskview-packages/taskview-api/src/api/permissions.ts @@ -142,8 +142,9 @@ export const TvPermissions: Record, keyof GoalP */ TIMETRACKING_CAN_LOG: 'timetracking_can_log', /** - * Edit/delete time entries of other project members. Includes viewing the - * full project 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; diff --git a/taskview-packages/taskview-api/src/api/time-tracking.types.ts b/taskview-packages/taskview-api/src/api/time-tracking.types.ts index 7e82992..e52f8b3 100644 --- a/taskview-packages/taskview-api/src/api/time-tracking.types.ts +++ b/taskview-packages/taskview-api/src/api/time-tracking.types.ts @@ -3,6 +3,7 @@ export type TimeEntryItem = { taskId: number goalId: number userId: number + userEmail: string | null startedAt: string endedAt: string | null durationSeconds: number | null diff --git a/web/src/components/features/tasks/parts/TaskTimeTracking.vue b/web/src/components/features/tasks/parts/TaskTimeTracking.vue index 6d7bb46..71ce0ff 100644 --- a/web/src/components/features/tasks/parts/TaskTimeTracking.vue +++ b/web/src/components/features/tasks/parts/TaskTimeTracking.vue @@ -56,7 +56,10 @@ {{ t('timeTracking.willStopOther') }} -
+
+
+ + {{ t('timeTracking.empty') }} +
- - {{ formattedStarted }} - +
+ {{ formattedStarted }} + · {{ userLabel }} +
userStore.payloadData?.id === props.entry.userId) const canEdit = computed(() => (isOwn.value ? canLogTime.value : canManageAllTime.value)) const formattedStarted = computed(() => new Date(props.entry.startedAt).toLocaleString()) +const userLabel = computed(() => + isOwn.value ? t('timeTracking.you') : (props.entry.userEmail ?? t('timeTracking.unknownUser')), +) const onSubmit = (payload: TimeEntryFormPayload) => { emit('update', { id: props.entry.id, ...payload }) diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 9f61477..ad9c9af 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -679,6 +679,9 @@ export default { }, timeTracking: { title: 'Time tracking', + empty: 'No time entries yet', + you: 'You', + unknownUser: 'Unknown user', start: 'Start', stop: 'Stop', running: 'Running', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 00d6f0a..026fc48 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -611,6 +611,9 @@ export default { }, timeTracking: { title: 'Учёт времени', + empty: 'Записей времени пока нет', + you: 'Вы', + unknownUser: 'Неизвестный пользователь', start: 'Запустить', stop: 'Остановить', running: 'Идёт',