@nuxt-customer-portal/timesheets 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/app/assets/timesheets.css +368 -0
- package/app/components/TimesheetsActivityForm.vue +61 -0
- package/app/components/TimesheetsAdminActivities.vue +323 -0
- package/app/components/TimesheetsAdminApprovals.vue +449 -0
- package/app/components/TimesheetsAdminEmptyState.vue +28 -0
- package/app/components/TimesheetsAdminListToolbar.vue +19 -0
- package/app/components/TimesheetsAdminPaginatedList.vue +95 -0
- package/app/components/TimesheetsAdminProjects.vue +234 -0
- package/app/components/TimesheetsAdminRates.vue +83 -0
- package/app/components/TimesheetsAdminReports.vue +123 -0
- package/app/components/TimesheetsAdminSectionPage.vue +135 -0
- package/app/components/TimesheetsAdminSettings.vue +15 -0
- package/app/components/TimesheetsAdministrationOrganizationPanel.vue +26 -0
- package/app/components/TimesheetsClientSettingsPanel.vue +52 -0
- package/app/components/TimesheetsClientTimesheetBrowser.vue +439 -0
- package/app/components/TimesheetsDashboardCard.vue +260 -0
- package/app/components/TimesheetsDashboardClientApprovals.vue +1 -0
- package/app/components/TimesheetsDashboardInternalApprovals.vue +1 -0
- package/app/components/TimesheetsDashboardMyTimesheets.vue +1 -0
- package/app/components/TimesheetsDashboardMyWeek.vue +1 -0
- package/app/components/TimesheetsDashboardSupplierTimesheets.vue +47 -0
- package/app/components/TimesheetsOrganizationFeaturesCard.vue +66 -0
- package/app/components/TimesheetsPageShell.vue +48 -0
- package/app/components/TimesheetsProjectDetail.vue +338 -0
- package/app/components/TimesheetsProjectForm.vue +215 -0
- package/app/components/TimesheetsSetupChecklist.vue +48 -0
- package/app/components/TimesheetsSubmissionMessages.vue +30 -0
- package/app/components/TimesheetsSubmissionTimeline.vue +89 -0
- package/app/components/TimesheetsWorkspaceSettingsForm.vue +80 -0
- package/app/composables/useTimesheetMenu.ts +69 -0
- package/app/composables/useTimesheetMutationError.ts +35 -0
- package/app/composables/useTimesheets.ts +248 -0
- package/app/composables/useTimesheetsAdminList.ts +165 -0
- package/app/composables/useTimesheetsDashboard.ts +8 -0
- package/app/pages/admin/timesheets/activities.vue +3 -0
- package/app/pages/admin/timesheets/approvals.vue +3 -0
- package/app/pages/admin/timesheets/index.vue +7 -0
- package/app/pages/admin/timesheets/internal-approvals.vue +131 -0
- package/app/pages/admin/timesheets/projects/[id].vue +57 -0
- package/app/pages/admin/timesheets/projects.vue +8 -0
- package/app/pages/admin/timesheets/rates.vue +3 -0
- package/app/pages/admin/timesheets/reports.vue +3 -0
- package/app/pages/admin/timesheets/settings.vue +3 -0
- package/app/pages/timesheets/approvals/reviewers.vue +96 -0
- package/app/pages/timesheets/approvals.vue +11 -0
- package/app/pages/timesheets/client-approvals.vue +108 -0
- package/app/pages/timesheets/client.vue +17 -0
- package/app/pages/timesheets/index.vue +1458 -0
- package/app/pages/timesheets/internal-approvals.vue +100 -0
- package/app/pages/timesheets/suppliers.vue +23 -0
- package/app/plugins/timesheets-feature.ts +122 -0
- package/i18n/locales/en.json +455 -0
- package/i18n/locales/nl.json +455 -0
- package/migrations/0000_baseline.sql +409 -0
- package/migrations/0001_remove_unused_legacy_invoice_schema.sql +31 -0
- package/migrations/0002_submission_batches.sql +47 -0
- package/migrations/0003_sync_provider_clients.sql +26 -0
- package/migrations/0004_client_reply.sql +1 -0
- package/migrations/0005_timer_rounding.sql +1 -0
- package/migrations/meta/0000_snapshot.json +3178 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +20 -0
- package/package.json +61 -0
- package/portal.manifest.mjs +10 -0
- package/server/api/timesheets/admin/activities/[id]/deletion.get.ts +18 -0
- package/server/api/timesheets/admin/activities/[id].delete.ts +20 -0
- package/server/api/timesheets/admin/activities/[id].patch.ts +19 -0
- package/server/api/timesheets/admin/activities/index.get.ts +19 -0
- package/server/api/timesheets/admin/activities/index.post.ts +19 -0
- package/server/api/timesheets/admin/approvals/[id].post.ts +19 -0
- package/server/api/timesheets/admin/bootstrap.get.ts +73 -0
- package/server/api/timesheets/admin/client-settings/[clientId]/index.get.ts +25 -0
- package/server/api/timesheets/admin/client-settings/[clientId]/index.put.ts +28 -0
- package/server/api/timesheets/admin/clients/[id]/deletion.get.ts +18 -0
- package/server/api/timesheets/admin/clients/[id].delete.ts +20 -0
- package/server/api/timesheets/admin/clients/[id].patch.ts +14 -0
- package/server/api/timesheets/admin/clients/index.get.ts +18 -0
- package/server/api/timesheets/admin/clients/index.post.ts +25 -0
- package/server/api/timesheets/admin/internal-approvals/[userId].put.ts +11 -0
- package/server/api/timesheets/admin/internal-approvals/index.get.ts +8 -0
- package/server/api/timesheets/admin/internal-approvals/index.patch.ts +11 -0
- package/server/api/timesheets/admin/organization-capabilities/[organizationId].get.ts +19 -0
- package/server/api/timesheets/admin/organization-capabilities/[organizationId].patch.ts +23 -0
- package/server/api/timesheets/admin/projects/[id]/deletion.get.ts +18 -0
- package/server/api/timesheets/admin/projects/[id].delete.ts +20 -0
- package/server/api/timesheets/admin/projects/[id].get.ts +22 -0
- package/server/api/timesheets/admin/projects/[id].patch.ts +19 -0
- package/server/api/timesheets/admin/projects/index.get.ts +18 -0
- package/server/api/timesheets/admin/projects/index.post.ts +19 -0
- package/server/api/timesheets/admin/report.get.ts +26 -0
- package/server/api/timesheets/admin/settings.patch.ts +19 -0
- package/server/api/timesheets/admin/tariffs/index.put.ts +20 -0
- package/server/api/timesheets/admin/team/[userId].put.ts +24 -0
- package/server/api/timesheets/bootstrap.get.ts +24 -0
- package/server/api/timesheets/capabilities.get.ts +58 -0
- package/server/api/timesheets/client/[workspaceClientId]/index.get.ts +12 -0
- package/server/api/timesheets/client/[workspaceClientId]/reviewers.get.ts +10 -0
- package/server/api/timesheets/client/[workspaceClientId]/reviewers.put.ts +18 -0
- package/server/api/timesheets/client/[workspaceClientId]/reviews/[submissionId].post.ts +18 -0
- package/server/api/timesheets/client/approval-members.get.ts +11 -0
- package/server/api/timesheets/client/approval-suppliers.get.ts +8 -0
- package/server/api/timesheets/client/approvals.get.ts +14 -0
- package/server/api/timesheets/client/reviewer-suppliers.get.ts +10 -0
- package/server/api/timesheets/client/supplier-options.get.ts +14 -0
- package/server/api/timesheets/client/supplier-timesheets.get.ts +17 -0
- package/server/api/timesheets/client/workspaces.get.ts +11 -0
- package/server/api/timesheets/client-approvals.get.ts +18 -0
- package/server/api/timesheets/dashboard.get.ts +191 -0
- package/server/api/timesheets/entries/[id].delete.ts +18 -0
- package/server/api/timesheets/entries/[id].patch.ts +23 -0
- package/server/api/timesheets/entries/index.post.ts +22 -0
- package/server/api/timesheets/internal-approvals/[id].post.ts +9 -0
- package/server/api/timesheets/internal-approvals/index.get.ts +37 -0
- package/server/api/timesheets/internal-approvals/list.get.ts +12 -0
- package/server/api/timesheets/setup-status.get.ts +21 -0
- package/server/api/timesheets/submissions/[id]/client-reply.post.ts +20 -0
- package/server/api/timesheets/submissions/[id]/request-client-approval.post.ts +21 -0
- package/server/api/timesheets/submissions/[id]/resubmit.post.ts +10 -0
- package/server/api/timesheets/timer/index.delete.ts +18 -0
- package/server/api/timesheets/timer/index.post.ts +23 -0
- package/server/api/timesheets/weeks/[id]/submissions.post.ts +19 -0
- package/server/db/schema/timesheets.ts +383 -0
- package/server/plugins/client-sync.ts +30 -0
- package/server/plugins/openapi-contracts.ts +57 -0
- package/server/utils/timesheet-admin-listing.ts +281 -0
- package/server/utils/timesheet-email.ts +221 -0
- package/server/utils/timesheet-repository.ts +3096 -0
- package/server/utils/timesheet-validation.ts +231 -0
- package/shared/client-approval-dashboard.ts +17 -0
- package/shared/emails.ts +403 -0
- package/shared/feature.ts +184 -0
- package/shared/period-lock.ts +8 -0
- package/shared/submission-reply.ts +3 -0
- package/shared/timer-rounding.ts +2 -0
- package/shared/timesheet-dates.ts +23 -0
- package/shared/timesheet-errors.ts +13 -0
- package/shared/types/timesheet.ts +424 -0
- package/tokens.css +25 -0
|
@@ -0,0 +1,3096 @@
|
|
|
1
|
+
import { roundTimerMinutes } from '../../shared/timer-rounding'
|
|
2
|
+
import { notifyTimesheetEvent, notifyClientTimesheetEvent } from './timesheet-email'
|
|
3
|
+
import { nanoid } from 'nanoid'
|
|
4
|
+
import { isTimesheetDateLocked } from '@nuxt-customer-portal/timesheets/shared/period-lock'
|
|
5
|
+
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm'
|
|
6
|
+
import {
|
|
7
|
+
db,
|
|
8
|
+
getPortalOrganizationsByIds,
|
|
9
|
+
listPortalOrganizationMembers
|
|
10
|
+
} from '@nuxt-customer-portal/core/server/portal'
|
|
11
|
+
import { assertTimeEntriesReopenable } from '@nuxt-customer-portal/core/server/utils/business-hooks'
|
|
12
|
+
import {
|
|
13
|
+
getClient as getSharedClient,
|
|
14
|
+
listSelectableClients
|
|
15
|
+
} from '@nuxt-customer-portal/clients/server/utils/client-repository'
|
|
16
|
+
import { organization, user } from '@nuxt-customer-portal/core/schema'
|
|
17
|
+
import {
|
|
18
|
+
activityType,
|
|
19
|
+
internalApproverAssignment,
|
|
20
|
+
project,
|
|
21
|
+
projectActivity,
|
|
22
|
+
projectPersonTariff,
|
|
23
|
+
teamMemberSettings,
|
|
24
|
+
teamTariff,
|
|
25
|
+
timeEntry,
|
|
26
|
+
timesheetClientReview,
|
|
27
|
+
timesheetClientReviewHistory,
|
|
28
|
+
timesheetApprovalHistory,
|
|
29
|
+
timesheetSubmission,
|
|
30
|
+
weeklyTimesheet,
|
|
31
|
+
workspaceClient,
|
|
32
|
+
workspaceClientReviewer,
|
|
33
|
+
workspaceSettings,
|
|
34
|
+
type TimeEntryRecord,
|
|
35
|
+
type TimesheetSubmissionRecord,
|
|
36
|
+
type WeeklyTimesheetRecord
|
|
37
|
+
} from '@nuxt-customer-portal/timesheets/server/db/schema/timesheets'
|
|
38
|
+
import type {
|
|
39
|
+
ActivityTypeDto,
|
|
40
|
+
ApprovalQueueItemDto,
|
|
41
|
+
ClientDto,
|
|
42
|
+
ProjectDto,
|
|
43
|
+
ReportRowDto,
|
|
44
|
+
TeamMemberDto,
|
|
45
|
+
TimesheetsSetupStatusDto,
|
|
46
|
+
TimesheetBootstrapDto,
|
|
47
|
+
TimesheetReportDto,
|
|
48
|
+
TimeEntryDto,
|
|
49
|
+
WeekDto,
|
|
50
|
+
ClientAccessMode,
|
|
51
|
+
ClientTimesheetsDto,
|
|
52
|
+
ClientWorkspaceDto,
|
|
53
|
+
ClientReviewerDto,
|
|
54
|
+
ClientApprovalsDto,
|
|
55
|
+
ClientReviewerSupplierDto,
|
|
56
|
+
ClientSupplierTimesheetItemDto,
|
|
57
|
+
InternalApprovalConfigurationDto
|
|
58
|
+
} from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
|
|
59
|
+
import { hasInvalidProjectActivityAssignments, type ReportQuery } from './timesheet-validation'
|
|
60
|
+
import { addIsoDays, mondayFor } from '@nuxt-customer-portal/timesheets/shared/timesheet-dates'
|
|
61
|
+
import { TIMESHEET_ERROR_CODES } from '@nuxt-customer-portal/timesheets/shared/timesheet-errors'
|
|
62
|
+
|
|
63
|
+
const toEntryDto = (row: TimeEntryRecord, submissionStatus: TimeEntryDto['submissionStatus'] = null): TimeEntryDto => ({
|
|
64
|
+
id: row.id,
|
|
65
|
+
projectId: row.projectId,
|
|
66
|
+
activityTypeId: row.activityTypeId,
|
|
67
|
+
entryDate: row.entryDate,
|
|
68
|
+
durationMinutes: row.durationMinutes,
|
|
69
|
+
note: row.note,
|
|
70
|
+
billable: row.billableSnapshot,
|
|
71
|
+
hourlyRateMinor: row.hourlyRateMinorSnapshot,
|
|
72
|
+
currency: row.currencySnapshot,
|
|
73
|
+
timerStartedAt: row.timerStartedAt?.toISOString() ?? null,
|
|
74
|
+
submissionId: row.submissionId,
|
|
75
|
+
submissionStatus
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const submissionMessages = async (ids: string[]) => {
|
|
79
|
+
if (!ids.length) {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
const rows = await db
|
|
83
|
+
.select({ history: timesheetApprovalHistory, name: user.name, image: user.image })
|
|
84
|
+
.from(timesheetApprovalHistory)
|
|
85
|
+
.leftJoin(user, eq(user.id, timesheetApprovalHistory.actorUserId))
|
|
86
|
+
.where(
|
|
87
|
+
and(
|
|
88
|
+
inArray(timesheetApprovalHistory.submissionId, ids),
|
|
89
|
+
inArray(timesheetApprovalHistory.action, ['SUBMITTED', 'REJECTED', 'APPROVED'])
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
.orderBy(asc(timesheetApprovalHistory.createdAt), asc(timesheetApprovalHistory.id))
|
|
93
|
+
return rows
|
|
94
|
+
.filter(
|
|
95
|
+
(row) =>
|
|
96
|
+
row.history.comment?.trim() &&
|
|
97
|
+
row.history.comment !== 'Automatically approved because internal approval is disabled'
|
|
98
|
+
)
|
|
99
|
+
.map((row) => ({
|
|
100
|
+
id: row.history.id,
|
|
101
|
+
submissionId: row.history.submissionId,
|
|
102
|
+
authorUserId: row.history.actorUserId,
|
|
103
|
+
authorName: row.name,
|
|
104
|
+
authorImage: row.image,
|
|
105
|
+
role: row.history.action === 'SUBMITTED' ? ('submitter' as const) : ('reviewer' as const),
|
|
106
|
+
comment: row.history.comment!,
|
|
107
|
+
createdAt: row.history.createdAt.toISOString()
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const clientSubmissionThreads = async (ids: string[]) => {
|
|
112
|
+
if (!ids.length) {
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
const reviews = await db
|
|
116
|
+
.select({ review: timesheetClientReview, clientName: organization.name })
|
|
117
|
+
.from(timesheetClientReview)
|
|
118
|
+
.innerJoin(organization, eq(organization.id, timesheetClientReview.clientOrganizationId))
|
|
119
|
+
.where(inArray(timesheetClientReview.submissionId, ids))
|
|
120
|
+
const history = await db
|
|
121
|
+
.select({ history: timesheetClientReviewHistory, name: user.name })
|
|
122
|
+
.from(timesheetClientReviewHistory)
|
|
123
|
+
.innerJoin(user, eq(user.id, timesheetClientReviewHistory.actorUserId))
|
|
124
|
+
.where(inArray(timesheetClientReviewHistory.submissionId, ids))
|
|
125
|
+
.orderBy(asc(timesheetClientReviewHistory.createdAt), asc(timesheetClientReviewHistory.id))
|
|
126
|
+
return reviews.map(({ review, clientName }) => ({
|
|
127
|
+
submissionId: review.submissionId,
|
|
128
|
+
clientOrganizationId: review.clientOrganizationId,
|
|
129
|
+
clientName,
|
|
130
|
+
status: review.status,
|
|
131
|
+
version: review.version,
|
|
132
|
+
messages: history
|
|
133
|
+
.filter(
|
|
134
|
+
(item) =>
|
|
135
|
+
item.history.submissionId === review.submissionId &&
|
|
136
|
+
item.history.clientOrganizationId === review.clientOrganizationId &&
|
|
137
|
+
item.history.comment?.trim()
|
|
138
|
+
)
|
|
139
|
+
.map(({ history: item, name }) => ({
|
|
140
|
+
id: item.id,
|
|
141
|
+
authorUserId: item.actorUserId,
|
|
142
|
+
authorName: name,
|
|
143
|
+
authorImage: null,
|
|
144
|
+
role: item.action === 'SUBMITTED' ? ('submitter' as const) : ('reviewer' as const),
|
|
145
|
+
comment: item.comment!,
|
|
146
|
+
createdAt: item.createdAt.toISOString()
|
|
147
|
+
}))
|
|
148
|
+
}))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const submissionHistory = async (ids: string[]) => {
|
|
152
|
+
if (!ids.length) {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
const [internal, client] = await Promise.all([
|
|
156
|
+
db
|
|
157
|
+
.select({ event: timesheetApprovalHistory, name: user.name, image: user.image })
|
|
158
|
+
.from(timesheetApprovalHistory)
|
|
159
|
+
.leftJoin(user, eq(user.id, timesheetApprovalHistory.actorUserId))
|
|
160
|
+
.where(inArray(timesheetApprovalHistory.submissionId, ids)),
|
|
161
|
+
db
|
|
162
|
+
.select({
|
|
163
|
+
event: timesheetClientReviewHistory,
|
|
164
|
+
name: user.name,
|
|
165
|
+
image: user.image,
|
|
166
|
+
clientName: organization.name
|
|
167
|
+
})
|
|
168
|
+
.from(timesheetClientReviewHistory)
|
|
169
|
+
.leftJoin(user, eq(user.id, timesheetClientReviewHistory.actorUserId))
|
|
170
|
+
.innerJoin(organization, eq(organization.id, timesheetClientReviewHistory.clientOrganizationId))
|
|
171
|
+
.where(inArray(timesheetClientReviewHistory.submissionId, ids))
|
|
172
|
+
])
|
|
173
|
+
return [
|
|
174
|
+
...internal.map(({ event, name, image }) => ({
|
|
175
|
+
id: event.id,
|
|
176
|
+
submissionId: event.submissionId,
|
|
177
|
+
action: event.action,
|
|
178
|
+
actorName: name,
|
|
179
|
+
actorImage: image,
|
|
180
|
+
actorUserId: event.actorUserId,
|
|
181
|
+
clientName: null,
|
|
182
|
+
clientOrganizationId: null,
|
|
183
|
+
comment: event.comment?.startsWith('Automatically approved because internal approval') ? null : event.comment,
|
|
184
|
+
createdAt: event.createdAt.toISOString()
|
|
185
|
+
})),
|
|
186
|
+
...client.map(({ event, name, image, clientName }) => ({
|
|
187
|
+
id: event.id,
|
|
188
|
+
submissionId: event.submissionId,
|
|
189
|
+
action: event.action,
|
|
190
|
+
actorName: name,
|
|
191
|
+
actorImage: image,
|
|
192
|
+
actorUserId: event.actorUserId,
|
|
193
|
+
clientName,
|
|
194
|
+
clientOrganizationId: event.clientOrganizationId,
|
|
195
|
+
comment: event.comment?.startsWith('Automatically approved because internal approval') ? null : event.comment,
|
|
196
|
+
createdAt: event.createdAt.toISOString()
|
|
197
|
+
}))
|
|
198
|
+
].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const toWeekDto = (
|
|
202
|
+
row: WeeklyTimesheetRecord,
|
|
203
|
+
entries: TimeEntryRecord[],
|
|
204
|
+
submissions: TimesheetSubmissionRecord[],
|
|
205
|
+
reviewers: Map<string, { name: string; image: string | null }>,
|
|
206
|
+
messages: Awaited<ReturnType<typeof submissionMessages>>,
|
|
207
|
+
clientThreads: Awaited<ReturnType<typeof clientSubmissionThreads>>,
|
|
208
|
+
history: Awaited<ReturnType<typeof submissionHistory>>
|
|
209
|
+
): WeekDto => {
|
|
210
|
+
const submissionById = new Map(submissions.map((submission) => [submission.id, submission]))
|
|
211
|
+
return {
|
|
212
|
+
id: row.id,
|
|
213
|
+
userId: row.userId,
|
|
214
|
+
weekStartsOn: row.weekStartsOn,
|
|
215
|
+
status: row.status,
|
|
216
|
+
submittedAt: row.submittedAt?.toISOString() ?? null,
|
|
217
|
+
reviewedAt: row.reviewedAt?.toISOString() ?? null,
|
|
218
|
+
reviewedById: row.reviewedById,
|
|
219
|
+
rejectionComment: row.rejectionComment,
|
|
220
|
+
entries: entries.map((entry) =>
|
|
221
|
+
toEntryDto(entry, entry.submissionId ? (submissionById.get(entry.submissionId)?.status ?? null) : null)
|
|
222
|
+
),
|
|
223
|
+
submissions: submissions.map((submission) => ({
|
|
224
|
+
id: submission.id,
|
|
225
|
+
history: history.filter((event) => event.submissionId === submission.id),
|
|
226
|
+
messages: messages.filter((message) => message.submissionId === submission.id),
|
|
227
|
+
clientThreads: clientThreads.filter((thread) => thread.submissionId === submission.id),
|
|
228
|
+
periodStartsOn: submission.periodStartsOn,
|
|
229
|
+
periodEndsOn: submission.periodEndsOn,
|
|
230
|
+
status: submission.status,
|
|
231
|
+
submittedAt: submission.submittedAt?.toISOString() ?? null,
|
|
232
|
+
reviewedAt: submission.reviewedAt?.toISOString() ?? null,
|
|
233
|
+
reviewedById: submission.reviewedById,
|
|
234
|
+
reviewerName: submission.reviewedById ? (reviewers.get(submission.reviewedById)?.name ?? null) : null,
|
|
235
|
+
reviewerImage: submission.reviewedById ? (reviewers.get(submission.reviewedById)?.image ?? null) : null,
|
|
236
|
+
rejectionComment: submission.rejectionComment,
|
|
237
|
+
version: submission.version,
|
|
238
|
+
entryIds: entries.filter((entry) => entry.submissionId === submission.id).map((entry) => entry.id)
|
|
239
|
+
}))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const hasDatabaseErrorCode = (error: unknown, code: string): boolean => {
|
|
244
|
+
let current = error as { code?: string; cause?: unknown } | undefined
|
|
245
|
+
while (current) {
|
|
246
|
+
if (current.code === code) {
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
current = current.cause as { code?: string; cause?: unknown } | undefined
|
|
250
|
+
}
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const currentIsoDateInTimezone = (timezone: string) => {
|
|
255
|
+
const parts = new Intl.DateTimeFormat('en', {
|
|
256
|
+
timeZone: timezone,
|
|
257
|
+
year: 'numeric',
|
|
258
|
+
month: '2-digit',
|
|
259
|
+
day: '2-digit'
|
|
260
|
+
}).formatToParts(new Date())
|
|
261
|
+
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ''
|
|
262
|
+
return `${value('year')}-${value('month')}-${value('day')}`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const listTeamMemberSettings = async (organizationId: string) => {
|
|
266
|
+
try {
|
|
267
|
+
return await db.select().from(teamMemberSettings).where(eq(teamMemberSettings.organizationId, organizationId))
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Keep capability discovery backward-compatible while a deployment migration is pending.
|
|
270
|
+
if (hasDatabaseErrorCode(error, '42P01')) {
|
|
271
|
+
return []
|
|
272
|
+
}
|
|
273
|
+
throw error
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const ensureSettings = async (organizationId: string) => {
|
|
278
|
+
const [settings] = await db
|
|
279
|
+
.select({ settings: workspaceSettings, organizationType: organization.organizationType })
|
|
280
|
+
.from(workspaceSettings)
|
|
281
|
+
.innerJoin(organization, eq(organization.id, workspaceSettings.organizationId))
|
|
282
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
283
|
+
.limit(1)
|
|
284
|
+
if (settings?.organizationType !== 'PROVIDER') {
|
|
285
|
+
throw createError({
|
|
286
|
+
statusCode: 403,
|
|
287
|
+
message: 'Timesheets workspaces are only available to provider organizations'
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (!settings.settings.workspaceEnabled) {
|
|
291
|
+
throw createError({ statusCode: 403, message: 'Timesheets workspace is not enabled' })
|
|
292
|
+
}
|
|
293
|
+
return settings.settings
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const requireTimesheetWorkspace = async (organizationId: string) => {
|
|
297
|
+
await ensureSettings(organizationId)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const getOrganizationTimesheetCapabilities = async (organizationId: string) => {
|
|
301
|
+
const [settings, clientLinks] = await Promise.all([
|
|
302
|
+
db.select().from(workspaceSettings).where(eq(workspaceSettings.organizationId, organizationId)).limit(1),
|
|
303
|
+
db
|
|
304
|
+
.select({
|
|
305
|
+
workspaceOrganizationId: workspaceClient.workspaceOrganizationId,
|
|
306
|
+
workspaceName: organization.name,
|
|
307
|
+
accessMode: workspaceClient.accessMode
|
|
308
|
+
})
|
|
309
|
+
.from(workspaceClient)
|
|
310
|
+
.innerJoin(organization, eq(organization.id, workspaceClient.workspaceOrganizationId))
|
|
311
|
+
.where(eq(workspaceClient.clientOrganizationId, organizationId))
|
|
312
|
+
.orderBy(asc(organization.name))
|
|
313
|
+
])
|
|
314
|
+
return { workspaceEnabled: settings[0]?.workspaceEnabled ?? false, clientOf: clientLinks }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const updateOrganizationTimesheetCapabilities = async (
|
|
318
|
+
organizationId: string,
|
|
319
|
+
input: { workspaceEnabled: boolean }
|
|
320
|
+
) => {
|
|
321
|
+
const [settings] = await db
|
|
322
|
+
.insert(workspaceSettings)
|
|
323
|
+
.values({ organizationId, ...input })
|
|
324
|
+
.onConflictDoUpdate({
|
|
325
|
+
target: workspaceSettings.organizationId,
|
|
326
|
+
set: { ...input, updatedAt: new Date() }
|
|
327
|
+
})
|
|
328
|
+
.returning()
|
|
329
|
+
return settings
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const ensureWeek = async (organizationId: string, userId: string, dateString?: string) => {
|
|
333
|
+
const weekStartsOn = mondayFor(dateString)
|
|
334
|
+
await db
|
|
335
|
+
.insert(weeklyTimesheet)
|
|
336
|
+
.values({
|
|
337
|
+
id: nanoid(),
|
|
338
|
+
organizationId,
|
|
339
|
+
userId,
|
|
340
|
+
weekStartsOn
|
|
341
|
+
})
|
|
342
|
+
.onConflictDoNothing()
|
|
343
|
+
const [week] = await db
|
|
344
|
+
.select()
|
|
345
|
+
.from(weeklyTimesheet)
|
|
346
|
+
.where(
|
|
347
|
+
and(
|
|
348
|
+
eq(weeklyTimesheet.organizationId, organizationId),
|
|
349
|
+
eq(weeklyTimesheet.userId, userId),
|
|
350
|
+
eq(weeklyTimesheet.weekStartsOn, weekStartsOn)
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
.limit(1)
|
|
354
|
+
if (!week) {
|
|
355
|
+
throw createError({ statusCode: 500, message: 'Timesheet week unavailable' })
|
|
356
|
+
}
|
|
357
|
+
return week
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export const listClients = async (organizationId: string): Promise<ClientDto[]> => {
|
|
361
|
+
const selectableClients = await listSelectableClients()
|
|
362
|
+
const existingLinks = await db
|
|
363
|
+
.select({ clientOrganizationId: workspaceClient.clientOrganizationId })
|
|
364
|
+
.from(workspaceClient)
|
|
365
|
+
.where(eq(workspaceClient.workspaceOrganizationId, organizationId))
|
|
366
|
+
const linkedIds = new Set(existingLinks.map((link) => link.clientOrganizationId))
|
|
367
|
+
const missingClients = selectableClients.filter((client) => !linkedIds.has(client.id))
|
|
368
|
+
if (missingClients.length) {
|
|
369
|
+
await db
|
|
370
|
+
.insert(workspaceClient)
|
|
371
|
+
.values(
|
|
372
|
+
missingClients.map((client) => ({
|
|
373
|
+
id: nanoid(),
|
|
374
|
+
workspaceOrganizationId: organizationId,
|
|
375
|
+
clientOrganizationId: client.id
|
|
376
|
+
}))
|
|
377
|
+
)
|
|
378
|
+
.onConflictDoNothing()
|
|
379
|
+
}
|
|
380
|
+
const links = await db
|
|
381
|
+
.select()
|
|
382
|
+
.from(workspaceClient)
|
|
383
|
+
.where(eq(workspaceClient.workspaceOrganizationId, organizationId))
|
|
384
|
+
if (!links.length) {
|
|
385
|
+
return []
|
|
386
|
+
}
|
|
387
|
+
const sharedClients = selectableClients
|
|
388
|
+
const byId = new Map(sharedClients.map((item) => [item!.organizationId, item!]))
|
|
389
|
+
return links
|
|
390
|
+
.flatMap((link) => {
|
|
391
|
+
const client = byId.get(link.clientOrganizationId)
|
|
392
|
+
return client
|
|
393
|
+
? [
|
|
394
|
+
{
|
|
395
|
+
id: link.id,
|
|
396
|
+
organizationId: client.id,
|
|
397
|
+
name: client.name,
|
|
398
|
+
officialName: client.officialName,
|
|
399
|
+
slug: client.slug,
|
|
400
|
+
logo: client.logo,
|
|
401
|
+
accessMode: link.accessMode
|
|
402
|
+
}
|
|
403
|
+
]
|
|
404
|
+
: []
|
|
405
|
+
})
|
|
406
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const listAvailableClientOrganizations = async (
|
|
410
|
+
organizationId: string,
|
|
411
|
+
_userId: string,
|
|
412
|
+
_hasSystemAccess: boolean
|
|
413
|
+
) => {
|
|
414
|
+
const [accessible, clients] = await Promise.all([listSelectableClients(), listClients(organizationId)])
|
|
415
|
+
const linkedIds = new Set(clients.map((client) => client.organizationId))
|
|
416
|
+
return accessible.filter((item) => !linkedIds.has(item.id))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export const linkClient = async (
|
|
420
|
+
organizationId: string,
|
|
421
|
+
_userId: string,
|
|
422
|
+
_hasSystemAccess: boolean,
|
|
423
|
+
clientOrganizationId: string
|
|
424
|
+
) => {
|
|
425
|
+
const client = await getSharedClient(clientOrganizationId)
|
|
426
|
+
if (!client) {
|
|
427
|
+
throw createError({ statusCode: 404, message: 'Client organization not found' })
|
|
428
|
+
}
|
|
429
|
+
const [link] = await db
|
|
430
|
+
.insert(workspaceClient)
|
|
431
|
+
.values({
|
|
432
|
+
id: nanoid(),
|
|
433
|
+
workspaceOrganizationId: organizationId,
|
|
434
|
+
clientOrganizationId: client.id
|
|
435
|
+
})
|
|
436
|
+
.onConflictDoNothing()
|
|
437
|
+
.returning()
|
|
438
|
+
if (!link) {
|
|
439
|
+
throw createError({ statusCode: 409, message: 'Client is already linked' })
|
|
440
|
+
}
|
|
441
|
+
return link
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export const ensureTimesheetClientSettings = async (organizationId: string, clientOrganizationId: string) => {
|
|
445
|
+
if (clientOrganizationId === organizationId) {
|
|
446
|
+
await ensureSettings(organizationId)
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
const [existing] = await db
|
|
450
|
+
.select()
|
|
451
|
+
.from(workspaceClient)
|
|
452
|
+
.where(
|
|
453
|
+
and(
|
|
454
|
+
eq(workspaceClient.workspaceOrganizationId, organizationId),
|
|
455
|
+
eq(workspaceClient.clientOrganizationId, clientOrganizationId)
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
.limit(1)
|
|
459
|
+
if (existing) {
|
|
460
|
+
return existing
|
|
461
|
+
}
|
|
462
|
+
const [created] = await db
|
|
463
|
+
.insert(workspaceClient)
|
|
464
|
+
.values({ id: nanoid(), workspaceOrganizationId: organizationId, clientOrganizationId })
|
|
465
|
+
.returning()
|
|
466
|
+
return created
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export const updateClientAccess = async (organizationId: string, id: string, accessMode: ClientAccessMode) => {
|
|
470
|
+
const [updated] = await db
|
|
471
|
+
.update(workspaceClient)
|
|
472
|
+
.set({ accessMode, updatedAt: new Date() })
|
|
473
|
+
.where(and(eq(workspaceClient.id, id), eq(workspaceClient.workspaceOrganizationId, organizationId)))
|
|
474
|
+
.returning()
|
|
475
|
+
if (!updated) {
|
|
476
|
+
throw createError({ statusCode: 404, message: 'Client not found' })
|
|
477
|
+
}
|
|
478
|
+
return updated
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export const listClientWorkspaces = async (
|
|
482
|
+
clientOrganizationId: string,
|
|
483
|
+
userId: string,
|
|
484
|
+
canManageReviewers: boolean
|
|
485
|
+
): Promise<ClientWorkspaceDto[]> => {
|
|
486
|
+
const links = await db
|
|
487
|
+
.select({ link: workspaceClient, workspaceName: organization.name })
|
|
488
|
+
.from(workspaceClient)
|
|
489
|
+
.innerJoin(organization, eq(organization.id, workspaceClient.workspaceOrganizationId))
|
|
490
|
+
.where(
|
|
491
|
+
and(
|
|
492
|
+
eq(workspaceClient.clientOrganizationId, clientOrganizationId),
|
|
493
|
+
inArray(workspaceClient.accessMode, ['VIEW', 'REVIEW'])
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
.orderBy(asc(organization.name))
|
|
497
|
+
const reviews = links.length
|
|
498
|
+
? await db
|
|
499
|
+
.select()
|
|
500
|
+
.from(workspaceClientReviewer)
|
|
501
|
+
.where(
|
|
502
|
+
and(
|
|
503
|
+
inArray(
|
|
504
|
+
workspaceClientReviewer.workspaceClientId,
|
|
505
|
+
links.map((item) => item.link.id)
|
|
506
|
+
),
|
|
507
|
+
eq(workspaceClientReviewer.userId, userId)
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
: []
|
|
511
|
+
const reviewerLinks = new Set(reviews.map((item) => item.workspaceClientId))
|
|
512
|
+
return links.map(({ link, workspaceName }) => ({
|
|
513
|
+
id: link.id,
|
|
514
|
+
workspaceOrganizationId: link.workspaceOrganizationId,
|
|
515
|
+
workspaceName,
|
|
516
|
+
accessMode: link.accessMode,
|
|
517
|
+
canReview: link.accessMode === 'REVIEW' && reviewerLinks.has(link.id),
|
|
518
|
+
canManageReviewers
|
|
519
|
+
}))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export const ensureClientReviewers = async (workspaceClientId: string, clientOrganizationId: string) => {
|
|
523
|
+
const members = await listPortalOrganizationMembers(clientOrganizationId)
|
|
524
|
+
await db.transaction(async (tx) => {
|
|
525
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${'client-reviewers:' + workspaceClientId}))`)
|
|
526
|
+
const assigned = await tx
|
|
527
|
+
.select()
|
|
528
|
+
.from(workspaceClientReviewer)
|
|
529
|
+
.where(eq(workspaceClientReviewer.workspaceClientId, workspaceClientId))
|
|
530
|
+
if (!assigned.length) {
|
|
531
|
+
const admins = members.filter((item) => ['owner', 'admin'].includes(item.organizationRole))
|
|
532
|
+
if (admins.length) {
|
|
533
|
+
await tx
|
|
534
|
+
.insert(workspaceClientReviewer)
|
|
535
|
+
.values(
|
|
536
|
+
admins.map((item) => ({
|
|
537
|
+
id: nanoid(),
|
|
538
|
+
workspaceClientId,
|
|
539
|
+
userId: item.id,
|
|
540
|
+
createdById: item.id
|
|
541
|
+
}))
|
|
542
|
+
)
|
|
543
|
+
.onConflictDoNothing()
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export const listClientReviewers = async (
|
|
550
|
+
workspaceClientId: string,
|
|
551
|
+
clientOrganizationId: string
|
|
552
|
+
): Promise<ClientReviewerDto[]> => {
|
|
553
|
+
const [link] = await db
|
|
554
|
+
.select()
|
|
555
|
+
.from(workspaceClient)
|
|
556
|
+
.where(
|
|
557
|
+
and(eq(workspaceClient.id, workspaceClientId), eq(workspaceClient.clientOrganizationId, clientOrganizationId))
|
|
558
|
+
)
|
|
559
|
+
.limit(1)
|
|
560
|
+
if (!link || link.accessMode === 'DISABLED') {
|
|
561
|
+
throw createError({ statusCode: 404, message: 'Client workspace not found' })
|
|
562
|
+
}
|
|
563
|
+
await ensureClientReviewers(workspaceClientId, clientOrganizationId)
|
|
564
|
+
const [members, assigned] = await Promise.all([
|
|
565
|
+
listPortalOrganizationMembers(clientOrganizationId),
|
|
566
|
+
db.select().from(workspaceClientReviewer).where(eq(workspaceClientReviewer.workspaceClientId, workspaceClientId))
|
|
567
|
+
])
|
|
568
|
+
const assignedIds = new Set(assigned.map((item) => item.userId))
|
|
569
|
+
return members.map((item) => {
|
|
570
|
+
const fixedAccess = false
|
|
571
|
+
return {
|
|
572
|
+
id: item.id,
|
|
573
|
+
name: item.name,
|
|
574
|
+
email: item.email,
|
|
575
|
+
role: item.organizationRole,
|
|
576
|
+
assigned: assignedIds.has(item.id),
|
|
577
|
+
fixedAccess
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export const listClientReviewerSuppliers = async (
|
|
583
|
+
clientOrganizationId: string,
|
|
584
|
+
userId: string
|
|
585
|
+
): Promise<ClientReviewerSupplierDto[]> => {
|
|
586
|
+
const workspaces = (await listClientWorkspaces(clientOrganizationId, userId, true)).filter(
|
|
587
|
+
(item) => item.accessMode === 'REVIEW'
|
|
588
|
+
)
|
|
589
|
+
if (!workspaces.length) {
|
|
590
|
+
return []
|
|
591
|
+
}
|
|
592
|
+
await Promise.all(workspaces.map((workspace) => ensureClientReviewers(workspace.id, clientOrganizationId)))
|
|
593
|
+
const [reviewers, pending] = await Promise.all([
|
|
594
|
+
db
|
|
595
|
+
.select()
|
|
596
|
+
.from(workspaceClientReviewer)
|
|
597
|
+
.where(
|
|
598
|
+
inArray(
|
|
599
|
+
workspaceClientReviewer.workspaceClientId,
|
|
600
|
+
workspaces.map((item) => item.id)
|
|
601
|
+
)
|
|
602
|
+
),
|
|
603
|
+
db
|
|
604
|
+
.select({ supplierOrganizationId: timesheetSubmission.organizationId })
|
|
605
|
+
.from(timesheetClientReview)
|
|
606
|
+
.innerJoin(timesheetSubmission, eq(timesheetSubmission.id, timesheetClientReview.submissionId))
|
|
607
|
+
.where(
|
|
608
|
+
and(
|
|
609
|
+
eq(timesheetClientReview.clientOrganizationId, clientOrganizationId),
|
|
610
|
+
eq(timesheetClientReview.status, 'PENDING'),
|
|
611
|
+
eq(timesheetSubmission.status, 'APPROVED')
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
])
|
|
615
|
+
return workspaces.map((workspace) => {
|
|
616
|
+
const reviewerIds = new Set(
|
|
617
|
+
reviewers.filter((item) => item.workspaceClientId === workspace.id).map((item) => item.userId)
|
|
618
|
+
)
|
|
619
|
+
return {
|
|
620
|
+
...workspace,
|
|
621
|
+
reviewerCount: reviewerIds.size,
|
|
622
|
+
pendingCount: pending.filter((item) => item.supplierOrganizationId === workspace.workspaceOrganizationId).length
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export const setClientReviewer = async (
|
|
628
|
+
workspaceClientId: string,
|
|
629
|
+
clientOrganizationId: string,
|
|
630
|
+
actorUserId: string,
|
|
631
|
+
userId: string,
|
|
632
|
+
assigned: boolean
|
|
633
|
+
) => {
|
|
634
|
+
const eligible = await listClientReviewers(workspaceClientId, clientOrganizationId)
|
|
635
|
+
const selected = eligible.find((item) => item.id === userId)
|
|
636
|
+
if (!selected) {
|
|
637
|
+
throw createError({ statusCode: 400, message: 'Reviewer must be a current client member' })
|
|
638
|
+
}
|
|
639
|
+
return db.transaction(async (tx) => {
|
|
640
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${'client-reviewers:' + workspaceClientId}))`)
|
|
641
|
+
if (!assigned) {
|
|
642
|
+
const current = await tx
|
|
643
|
+
.select()
|
|
644
|
+
.from(workspaceClientReviewer)
|
|
645
|
+
.where(eq(workspaceClientReviewer.workspaceClientId, workspaceClientId))
|
|
646
|
+
const memberIds = new Set(eligible.map((item) => item.id))
|
|
647
|
+
if (!current.some((item) => item.userId !== userId && memberIds.has(item.userId))) {
|
|
648
|
+
throw createError({ statusCode: 409, message: 'At least one reviewer must remain assigned' })
|
|
649
|
+
}
|
|
650
|
+
await tx
|
|
651
|
+
.delete(workspaceClientReviewer)
|
|
652
|
+
.where(
|
|
653
|
+
and(
|
|
654
|
+
eq(workspaceClientReviewer.workspaceClientId, workspaceClientId),
|
|
655
|
+
eq(workspaceClientReviewer.userId, userId)
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
} else {
|
|
659
|
+
await tx
|
|
660
|
+
.insert(workspaceClientReviewer)
|
|
661
|
+
.values({ id: nanoid(), workspaceClientId, userId, createdById: actorUserId })
|
|
662
|
+
.onConflictDoNothing()
|
|
663
|
+
}
|
|
664
|
+
return { assigned }
|
|
665
|
+
})
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export const listClientApprovals = async (
|
|
669
|
+
clientOrganizationId: string,
|
|
670
|
+
actorUserId: string,
|
|
671
|
+
isAdmin: boolean
|
|
672
|
+
): Promise<ClientApprovalsDto> => {
|
|
673
|
+
const links = await db
|
|
674
|
+
.select({ link: workspaceClient, supplierName: organization.name })
|
|
675
|
+
.from(workspaceClient)
|
|
676
|
+
.innerJoin(organization, eq(organization.id, workspaceClient.workspaceOrganizationId))
|
|
677
|
+
.where(
|
|
678
|
+
and(eq(workspaceClient.clientOrganizationId, clientOrganizationId), eq(workspaceClient.accessMode, 'REVIEW'))
|
|
679
|
+
)
|
|
680
|
+
if (!links.length) {
|
|
681
|
+
return { isAdmin, pendingCount: 0, items: [] }
|
|
682
|
+
}
|
|
683
|
+
const reviews = await db
|
|
684
|
+
.select()
|
|
685
|
+
.from(timesheetClientReview)
|
|
686
|
+
.innerJoin(timesheetSubmission, eq(timesheetSubmission.id, timesheetClientReview.submissionId))
|
|
687
|
+
.innerJoin(weeklyTimesheet, eq(weeklyTimesheet.id, timesheetSubmission.weeklyTimesheetId))
|
|
688
|
+
.where(
|
|
689
|
+
and(
|
|
690
|
+
eq(timesheetClientReview.clientOrganizationId, clientOrganizationId),
|
|
691
|
+
eq(timesheetSubmission.status, 'APPROVED')
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
const submissionIds = reviews.map((item) => item.client_review.submissionId).filter((id): id is string => Boolean(id))
|
|
695
|
+
if (!submissionIds.length) {
|
|
696
|
+
return { isAdmin, pendingCount: 0, items: [] }
|
|
697
|
+
}
|
|
698
|
+
const timeline = await submissionHistory(submissionIds)
|
|
699
|
+
const clientThreads = await clientSubmissionThreads(submissionIds)
|
|
700
|
+
const [assignments, acted, entries, internalHistory, clientHistory] = await Promise.all([
|
|
701
|
+
db
|
|
702
|
+
.select()
|
|
703
|
+
.from(workspaceClientReviewer)
|
|
704
|
+
.where(
|
|
705
|
+
and(
|
|
706
|
+
inArray(
|
|
707
|
+
workspaceClientReviewer.workspaceClientId,
|
|
708
|
+
links.map((item) => item.link.id)
|
|
709
|
+
),
|
|
710
|
+
eq(workspaceClientReviewer.userId, actorUserId)
|
|
711
|
+
)
|
|
712
|
+
),
|
|
713
|
+
db
|
|
714
|
+
.select({ submissionId: timesheetClientReviewHistory.submissionId })
|
|
715
|
+
.from(timesheetClientReviewHistory)
|
|
716
|
+
.where(
|
|
717
|
+
and(
|
|
718
|
+
eq(timesheetClientReviewHistory.clientOrganizationId, clientOrganizationId),
|
|
719
|
+
eq(timesheetClientReviewHistory.actorUserId, actorUserId)
|
|
720
|
+
)
|
|
721
|
+
),
|
|
722
|
+
db
|
|
723
|
+
.select({ entry: timeEntry, projectName: project.name, activityName: activityType.name, personName: user.name })
|
|
724
|
+
.from(timeEntry)
|
|
725
|
+
.innerJoin(project, eq(project.id, timeEntry.projectId))
|
|
726
|
+
.innerJoin(activityType, eq(activityType.id, timeEntry.activityTypeId))
|
|
727
|
+
.innerJoin(user, eq(user.id, timeEntry.userId))
|
|
728
|
+
.where(
|
|
729
|
+
and(inArray(timeEntry.submissionId, submissionIds), eq(timeEntry.clientOrganizationId, clientOrganizationId))
|
|
730
|
+
)
|
|
731
|
+
.orderBy(asc(timeEntry.entryDate), asc(timeEntry.createdAt)),
|
|
732
|
+
db
|
|
733
|
+
.select({ history: timesheetApprovalHistory, actorName: user.name })
|
|
734
|
+
.from(timesheetApprovalHistory)
|
|
735
|
+
.innerJoin(user, eq(user.id, timesheetApprovalHistory.actorUserId))
|
|
736
|
+
.where(
|
|
737
|
+
and(
|
|
738
|
+
inArray(timesheetApprovalHistory.submissionId, submissionIds),
|
|
739
|
+
inArray(timesheetApprovalHistory.action, ['SUBMITTED', 'APPROVED', 'REOPENED'])
|
|
740
|
+
)
|
|
741
|
+
),
|
|
742
|
+
db
|
|
743
|
+
.select({ history: timesheetClientReviewHistory, actorName: user.name })
|
|
744
|
+
.from(timesheetClientReviewHistory)
|
|
745
|
+
.innerJoin(user, eq(user.id, timesheetClientReviewHistory.actorUserId))
|
|
746
|
+
.where(
|
|
747
|
+
and(
|
|
748
|
+
inArray(timesheetClientReviewHistory.submissionId, submissionIds),
|
|
749
|
+
eq(timesheetClientReviewHistory.clientOrganizationId, clientOrganizationId)
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
])
|
|
753
|
+
const assignedLinks = new Set(assignments.map((item) => item.workspaceClientId))
|
|
754
|
+
const actedSubmissions = new Set(acted.map((item) => item.submissionId))
|
|
755
|
+
const reviewerIds = [
|
|
756
|
+
...new Set(reviews.map((item) => item.client_review.reviewerUserId).filter((id): id is string => Boolean(id)))
|
|
757
|
+
]
|
|
758
|
+
const reviewerUsers = reviewerIds.length
|
|
759
|
+
? await db.select({ id: user.id, name: user.name }).from(user).where(inArray(user.id, reviewerIds))
|
|
760
|
+
: []
|
|
761
|
+
const reviewerNames = new Map(reviewerUsers.map((item) => [item.id, item.name]))
|
|
762
|
+
const allAssignments = await db
|
|
763
|
+
.select()
|
|
764
|
+
.from(workspaceClientReviewer)
|
|
765
|
+
.where(
|
|
766
|
+
inArray(
|
|
767
|
+
workspaceClientReviewer.workspaceClientId,
|
|
768
|
+
links.map((item) => item.link.id)
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
const items = reviews
|
|
772
|
+
.flatMap(({ client_review: review, submission, weekly_timesheet: week }) => {
|
|
773
|
+
const link = links.find((item) => item.link.workspaceOrganizationId === submission.organizationId)
|
|
774
|
+
if (!link || (!isAdmin && !assignedLinks.has(link.link.id) && !actedSubmissions.has(submission.id))) {
|
|
775
|
+
return []
|
|
776
|
+
}
|
|
777
|
+
const rows = entries.filter((item) => item.entry.submissionId === submission.id)
|
|
778
|
+
if (!rows.length) {
|
|
779
|
+
return []
|
|
780
|
+
}
|
|
781
|
+
return [
|
|
782
|
+
{
|
|
783
|
+
id: review.id,
|
|
784
|
+
workspaceClientId: link.link.id,
|
|
785
|
+
supplierName: link.supplierName,
|
|
786
|
+
weeklyTimesheetId: submission.id,
|
|
787
|
+
submissionId: submission.id,
|
|
788
|
+
weekStartsOn: week.weekStartsOn,
|
|
789
|
+
periodStartsOn: submission.periodStartsOn,
|
|
790
|
+
periodEndsOn: submission.periodEndsOn,
|
|
791
|
+
person: rows[0]!.personName,
|
|
792
|
+
userId: submission.userId,
|
|
793
|
+
totalMinutes: rows.reduce((total, item) => total + item.entry.durationMinutes, 0),
|
|
794
|
+
status: review.status,
|
|
795
|
+
version: review.version,
|
|
796
|
+
comment: review.comment,
|
|
797
|
+
reviewedAt: review.reviewedAt?.toISOString() ?? null,
|
|
798
|
+
reviewerUserId: review.reviewerUserId,
|
|
799
|
+
reviewerName: review.reviewerUserId ? (reviewerNames.get(review.reviewerUserId) ?? null) : null,
|
|
800
|
+
messages:
|
|
801
|
+
clientThreads.find(
|
|
802
|
+
(thread) => thread.submissionId === submission.id && thread.clientOrganizationId === clientOrganizationId
|
|
803
|
+
)?.messages ?? [],
|
|
804
|
+
timeline: [
|
|
805
|
+
{
|
|
806
|
+
id: `client-review-created:${review.id}`,
|
|
807
|
+
action: 'CLIENT_SUBMITTED',
|
|
808
|
+
actorUserId: submission.userId,
|
|
809
|
+
actorName: rows[0]!.personName,
|
|
810
|
+
actorImage: null,
|
|
811
|
+
clientName: null,
|
|
812
|
+
comment: null,
|
|
813
|
+
// A client review is created only when time is released after internal approval.
|
|
814
|
+
createdAt: review.createdAt.toISOString()
|
|
815
|
+
},
|
|
816
|
+
...timeline.filter(
|
|
817
|
+
(event) => event.submissionId === submission.id && event.clientOrganizationId === clientOrganizationId
|
|
818
|
+
)
|
|
819
|
+
],
|
|
820
|
+
hasReviewers: allAssignments.some((item) => item.workspaceClientId === link.link.id),
|
|
821
|
+
canAct: review.status === 'PENDING' && (isAdmin || assignedLinks.has(link.link.id)),
|
|
822
|
+
canManageReviewers: isAdmin,
|
|
823
|
+
entries: rows.map(({ entry, projectName, activityName, personName }) => ({
|
|
824
|
+
id: entry.id,
|
|
825
|
+
date: entry.entryDate,
|
|
826
|
+
project: projectName,
|
|
827
|
+
person: personName,
|
|
828
|
+
activity: activityName,
|
|
829
|
+
minutes: entry.durationMinutes,
|
|
830
|
+
note: entry.note
|
|
831
|
+
})),
|
|
832
|
+
history: [
|
|
833
|
+
...internalHistory
|
|
834
|
+
.filter((item) => item.history.submissionId === submission.id)
|
|
835
|
+
.map((item) => ({
|
|
836
|
+
id: item.history.id,
|
|
837
|
+
action:
|
|
838
|
+
item.history.action === 'SUBMITTED'
|
|
839
|
+
? ('SUBMITTED' as const)
|
|
840
|
+
: item.history.action === 'REOPENED'
|
|
841
|
+
? ('REOPENED' as const)
|
|
842
|
+
: ('APPROVED_INTERNAL' as const),
|
|
843
|
+
actorName: item.actorName,
|
|
844
|
+
comment: null,
|
|
845
|
+
createdAt: item.history.createdAt.toISOString()
|
|
846
|
+
})),
|
|
847
|
+
...clientHistory
|
|
848
|
+
.filter((item) => item.history.submissionId === submission.id)
|
|
849
|
+
.map((item) => ({
|
|
850
|
+
id: item.history.id,
|
|
851
|
+
action:
|
|
852
|
+
item.history.action === 'SUBMITTED'
|
|
853
|
+
? ('SUBMITTED' as const)
|
|
854
|
+
: item.history.action === 'APPROVED'
|
|
855
|
+
? ('APPROVED_CLIENT' as const)
|
|
856
|
+
: ('DISPUTED_CLIENT' as const),
|
|
857
|
+
actorName: item.actorName,
|
|
858
|
+
comment: item.history.comment,
|
|
859
|
+
createdAt: item.history.createdAt.toISOString()
|
|
860
|
+
}))
|
|
861
|
+
].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
862
|
+
}
|
|
863
|
+
]
|
|
864
|
+
})
|
|
865
|
+
.sort((a, b) =>
|
|
866
|
+
a.status === b.status ? b.weekStartsOn.localeCompare(a.weekStartsOn) : a.status === 'PENDING' ? -1 : 1
|
|
867
|
+
)
|
|
868
|
+
return { isAdmin, pendingCount: items.filter((item) => item.status === 'PENDING' && item.canAct).length, items }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export const listClientApprovalSuppliers = async (
|
|
872
|
+
clientOrganizationId: string,
|
|
873
|
+
actorUserId: string,
|
|
874
|
+
isAdmin: boolean
|
|
875
|
+
) => {
|
|
876
|
+
const approvals = await listClientApprovals(clientOrganizationId, actorUserId, isAdmin)
|
|
877
|
+
return [
|
|
878
|
+
...new Map(
|
|
879
|
+
approvals.items.map((item) => [item.workspaceClientId, { id: item.workspaceClientId, name: item.supplierName }])
|
|
880
|
+
).values()
|
|
881
|
+
].sort((a, b) => a.name.localeCompare(b.name))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const findOwnedClient = async (organizationId: string, id: string) => {
|
|
885
|
+
const [link] = await db
|
|
886
|
+
.select()
|
|
887
|
+
.from(workspaceClient)
|
|
888
|
+
.where(and(eq(workspaceClient.id, id), eq(workspaceClient.workspaceOrganizationId, organizationId)))
|
|
889
|
+
.limit(1)
|
|
890
|
+
if (!link) {
|
|
891
|
+
throw createError({ statusCode: 404, message: 'Client not found' })
|
|
892
|
+
}
|
|
893
|
+
const [client] = await getPortalOrganizationsByIds([link.clientOrganizationId])
|
|
894
|
+
if (!client) {
|
|
895
|
+
throw createError({ statusCode: 404, message: 'Client organization not found' })
|
|
896
|
+
}
|
|
897
|
+
return { link, client }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export const getClientDeletionEligibility = async (organizationId: string, id: string) => {
|
|
901
|
+
const { link, client } = await findOwnedClient(organizationId, id)
|
|
902
|
+
const [existingProject] = await db
|
|
903
|
+
.select({ id: project.id })
|
|
904
|
+
.from(project)
|
|
905
|
+
.where(and(eq(project.organizationId, organizationId), eq(project.clientOrganizationId, link.clientOrganizationId)))
|
|
906
|
+
.limit(1)
|
|
907
|
+
return { clientId: link.id, clientName: client.name, canDelete: !existingProject }
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export const deleteClient = async (organizationId: string, id: string, clientName: string) =>
|
|
911
|
+
db.transaction(async (tx) => {
|
|
912
|
+
const [link] = await tx
|
|
913
|
+
.select()
|
|
914
|
+
.from(workspaceClient)
|
|
915
|
+
.where(and(eq(workspaceClient.id, id), eq(workspaceClient.workspaceOrganizationId, organizationId)))
|
|
916
|
+
.limit(1)
|
|
917
|
+
if (!link) {
|
|
918
|
+
throw createError({ statusCode: 404, message: 'Client not found' })
|
|
919
|
+
}
|
|
920
|
+
const [client] = await getPortalOrganizationsByIds([link.clientOrganizationId])
|
|
921
|
+
if (!client) {
|
|
922
|
+
throw createError({ statusCode: 404, message: 'Client organization not found' })
|
|
923
|
+
}
|
|
924
|
+
if (client.name !== clientName) {
|
|
925
|
+
throw createError({ statusCode: 400, message: 'Client name does not match' })
|
|
926
|
+
}
|
|
927
|
+
const [existingProject] = await tx
|
|
928
|
+
.select({ id: project.id })
|
|
929
|
+
.from(project)
|
|
930
|
+
.where(
|
|
931
|
+
and(eq(project.organizationId, organizationId), eq(project.clientOrganizationId, link.clientOrganizationId))
|
|
932
|
+
)
|
|
933
|
+
.limit(1)
|
|
934
|
+
if (existingProject) {
|
|
935
|
+
throw createError({ statusCode: 409, message: 'Clients with linked projects cannot be removed' })
|
|
936
|
+
}
|
|
937
|
+
await tx
|
|
938
|
+
.delete(workspaceClient)
|
|
939
|
+
.where(and(eq(workspaceClient.id, id), eq(workspaceClient.workspaceOrganizationId, organizationId)))
|
|
940
|
+
return { deleted: true }
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
export const listActivities = async (organizationId: string): Promise<ActivityTypeDto[]> =>
|
|
944
|
+
db
|
|
945
|
+
.select({
|
|
946
|
+
id: activityType.id,
|
|
947
|
+
name: activityType.name,
|
|
948
|
+
billable: activityType.billable,
|
|
949
|
+
active: activityType.active
|
|
950
|
+
})
|
|
951
|
+
.from(activityType)
|
|
952
|
+
.where(eq(activityType.organizationId, organizationId))
|
|
953
|
+
.orderBy(desc(activityType.active), asc(activityType.name))
|
|
954
|
+
|
|
955
|
+
export const createActivity = async (organizationId: string, input: { name: string; billable: boolean }) => {
|
|
956
|
+
const [created] = await db
|
|
957
|
+
.insert(activityType)
|
|
958
|
+
.values({
|
|
959
|
+
id: nanoid(),
|
|
960
|
+
organizationId,
|
|
961
|
+
...input
|
|
962
|
+
})
|
|
963
|
+
.returning()
|
|
964
|
+
return created
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export const updateActivity = async (
|
|
968
|
+
organizationId: string,
|
|
969
|
+
id: string,
|
|
970
|
+
input: Partial<{ name: string; billable: boolean; active: boolean }>
|
|
971
|
+
) => {
|
|
972
|
+
const [updated] = await db
|
|
973
|
+
.update(activityType)
|
|
974
|
+
.set(input)
|
|
975
|
+
.where(and(eq(activityType.id, id), eq(activityType.organizationId, organizationId)))
|
|
976
|
+
.returning()
|
|
977
|
+
if (!updated) {
|
|
978
|
+
throw createError({ statusCode: 404, message: 'Activity type not found' })
|
|
979
|
+
}
|
|
980
|
+
return updated
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const findOwnedActivity = async (organizationId: string, id: string) => {
|
|
984
|
+
const [selectedActivity] = await db
|
|
985
|
+
.select({
|
|
986
|
+
id: activityType.id,
|
|
987
|
+
name: activityType.name
|
|
988
|
+
})
|
|
989
|
+
.from(activityType)
|
|
990
|
+
.where(and(eq(activityType.id, id), eq(activityType.organizationId, organizationId)))
|
|
991
|
+
.limit(1)
|
|
992
|
+
if (!selectedActivity) {
|
|
993
|
+
throw createError({ statusCode: 404, message: 'Activity type not found' })
|
|
994
|
+
}
|
|
995
|
+
return selectedActivity
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export const getActivityDeletionEligibility = async (organizationId: string, id: string) => {
|
|
999
|
+
const selectedActivity = await findOwnedActivity(organizationId, id)
|
|
1000
|
+
const [existingEntry] = await db
|
|
1001
|
+
.select({ id: timeEntry.id })
|
|
1002
|
+
.from(timeEntry)
|
|
1003
|
+
.where(and(eq(timeEntry.organizationId, organizationId), eq(timeEntry.activityTypeId, id)))
|
|
1004
|
+
.limit(1)
|
|
1005
|
+
return {
|
|
1006
|
+
activityId: selectedActivity.id,
|
|
1007
|
+
activityName: selectedActivity.name,
|
|
1008
|
+
canDelete: !existingEntry
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export const deleteActivity = async (organizationId: string, id: string, activityName: string) =>
|
|
1013
|
+
db.transaction(async (tx) => {
|
|
1014
|
+
const [selectedActivity] = await tx
|
|
1015
|
+
.select({
|
|
1016
|
+
id: activityType.id,
|
|
1017
|
+
name: activityType.name
|
|
1018
|
+
})
|
|
1019
|
+
.from(activityType)
|
|
1020
|
+
.where(and(eq(activityType.id, id), eq(activityType.organizationId, organizationId)))
|
|
1021
|
+
.limit(1)
|
|
1022
|
+
if (!selectedActivity) {
|
|
1023
|
+
throw createError({ statusCode: 404, message: 'Activity type not found' })
|
|
1024
|
+
}
|
|
1025
|
+
if (selectedActivity.name !== activityName) {
|
|
1026
|
+
throw createError({ statusCode: 400, message: 'Activity name does not match' })
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const [existingEntry] = await tx
|
|
1030
|
+
.select({ id: timeEntry.id })
|
|
1031
|
+
.from(timeEntry)
|
|
1032
|
+
.where(and(eq(timeEntry.organizationId, organizationId), eq(timeEntry.activityTypeId, id)))
|
|
1033
|
+
.limit(1)
|
|
1034
|
+
if (existingEntry) {
|
|
1035
|
+
throw createError({ statusCode: 409, message: 'Activities with existing time entries cannot be deleted' })
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
await tx.delete(activityType).where(and(eq(activityType.id, id), eq(activityType.organizationId, organizationId)))
|
|
1039
|
+
return { deleted: true }
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
export const listTeam = async (organizationId: string): Promise<TeamMemberDto[]> => {
|
|
1043
|
+
const [members, tariffs, memberSettings, assignments] = await Promise.all([
|
|
1044
|
+
listPortalOrganizationMembers(organizationId),
|
|
1045
|
+
db.select().from(teamTariff).where(eq(teamTariff.organizationId, organizationId)),
|
|
1046
|
+
listTeamMemberSettings(organizationId),
|
|
1047
|
+
db.select().from(internalApproverAssignment).where(eq(internalApproverAssignment.organizationId, organizationId))
|
|
1048
|
+
])
|
|
1049
|
+
const tariffByUser = new Map(tariffs.map((item) => [item.userId, item.hourlyRateMinor]))
|
|
1050
|
+
const settingsByUser = new Map(memberSettings.map((item) => [item.userId, item.canEnterTime]))
|
|
1051
|
+
return members.map((item) => ({
|
|
1052
|
+
...item,
|
|
1053
|
+
image: item.image ?? null,
|
|
1054
|
+
defaultHourlyRateMinor: tariffByUser.get(item.id) ?? null,
|
|
1055
|
+
canEnterTime: settingsByUser.get(item.id) ?? true,
|
|
1056
|
+
internalApprovalRequired:
|
|
1057
|
+
memberSettings.find((setting) => setting.userId === item.id)?.internalApprovalRequired ?? true,
|
|
1058
|
+
approverUserIds: assignments
|
|
1059
|
+
.filter((assignment) => assignment.submitterUserId === item.id)
|
|
1060
|
+
.map((assignment) => assignment.approverUserId)
|
|
1061
|
+
}))
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
export const getInternalApprovalConfiguration = async (
|
|
1065
|
+
organizationId: string
|
|
1066
|
+
): Promise<InternalApprovalConfigurationDto> => {
|
|
1067
|
+
const [settings, members] = await Promise.all([ensureSettings(organizationId), listTeam(organizationId)])
|
|
1068
|
+
return { enabled: settings.internalApprovalsEnabled, members }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export const hasInternalApprovalAssignment = async (organizationId: string, approverUserId: string) => {
|
|
1072
|
+
const [assignment] = await db
|
|
1073
|
+
.select({ id: internalApproverAssignment.id })
|
|
1074
|
+
.from(internalApproverAssignment)
|
|
1075
|
+
.where(
|
|
1076
|
+
and(
|
|
1077
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
1078
|
+
eq(internalApproverAssignment.approverUserId, approverUserId)
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
.limit(1)
|
|
1082
|
+
return Boolean(assignment)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
type TimesheetTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
|
1086
|
+
|
|
1087
|
+
const autoApprovePendingWeeks = async (tx: TimesheetTransaction, organizationId: string, userId?: string) => {
|
|
1088
|
+
const conditions = [
|
|
1089
|
+
eq(timesheetSubmission.organizationId, organizationId),
|
|
1090
|
+
eq(timesheetSubmission.status, 'SUBMITTED')
|
|
1091
|
+
]
|
|
1092
|
+
if (userId) {
|
|
1093
|
+
conditions.push(eq(timesheetSubmission.userId, userId))
|
|
1094
|
+
}
|
|
1095
|
+
const approvedSubmissions: TimesheetSubmissionRecord[] = []
|
|
1096
|
+
const pending = await tx
|
|
1097
|
+
.select()
|
|
1098
|
+
.from(timesheetSubmission)
|
|
1099
|
+
.where(and(...conditions))
|
|
1100
|
+
for (const submission of pending) {
|
|
1101
|
+
const now = new Date()
|
|
1102
|
+
const [approved] = await tx
|
|
1103
|
+
.update(timesheetSubmission)
|
|
1104
|
+
.set({
|
|
1105
|
+
status: 'APPROVED',
|
|
1106
|
+
reviewedAt: now,
|
|
1107
|
+
reviewedById: null,
|
|
1108
|
+
rejectionComment: null,
|
|
1109
|
+
version: sql`${timesheetSubmission.version} + 1`
|
|
1110
|
+
})
|
|
1111
|
+
.where(and(eq(timesheetSubmission.id, submission.id), eq(timesheetSubmission.status, 'SUBMITTED')))
|
|
1112
|
+
.returning()
|
|
1113
|
+
if (!approved) {
|
|
1114
|
+
continue
|
|
1115
|
+
}
|
|
1116
|
+
approvedSubmissions.push(approved)
|
|
1117
|
+
await tx.insert(timesheetApprovalHistory).values({
|
|
1118
|
+
id: nanoid(),
|
|
1119
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
1120
|
+
submissionId: submission.id,
|
|
1121
|
+
action: 'APPROVED',
|
|
1122
|
+
actorUserId: submission.userId,
|
|
1123
|
+
comment: 'Automatically approved because internal approval was disabled'
|
|
1124
|
+
})
|
|
1125
|
+
const representedClients = await tx
|
|
1126
|
+
.selectDistinct({ clientOrganizationId: timeEntry.clientOrganizationId })
|
|
1127
|
+
.from(timeEntry)
|
|
1128
|
+
.innerJoin(
|
|
1129
|
+
workspaceClient,
|
|
1130
|
+
and(
|
|
1131
|
+
eq(workspaceClient.workspaceOrganizationId, organizationId),
|
|
1132
|
+
eq(workspaceClient.clientOrganizationId, timeEntry.clientOrganizationId),
|
|
1133
|
+
eq(workspaceClient.accessMode, 'REVIEW')
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
.where(eq(timeEntry.submissionId, submission.id))
|
|
1137
|
+
if (representedClients.length) {
|
|
1138
|
+
await tx
|
|
1139
|
+
.insert(timesheetClientReview)
|
|
1140
|
+
.values(
|
|
1141
|
+
representedClients.map((item) => ({
|
|
1142
|
+
id: nanoid(),
|
|
1143
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
1144
|
+
submissionId: submission.id,
|
|
1145
|
+
clientOrganizationId: item.clientOrganizationId,
|
|
1146
|
+
status: 'PENDING' as const
|
|
1147
|
+
}))
|
|
1148
|
+
)
|
|
1149
|
+
.onConflictDoNothing()
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return approvedSubmissions
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export const updateInternalApprovalWorkspace = async (organizationId: string, enabled: boolean) => {
|
|
1156
|
+
await ensureSettings(organizationId)
|
|
1157
|
+
const approved = await db.transaction(async (tx) => {
|
|
1158
|
+
await tx
|
|
1159
|
+
.update(workspaceSettings)
|
|
1160
|
+
.set({ internalApprovalsEnabled: enabled })
|
|
1161
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
1162
|
+
if (!enabled) {
|
|
1163
|
+
return autoApprovePendingWeeks(tx, organizationId)
|
|
1164
|
+
}
|
|
1165
|
+
return []
|
|
1166
|
+
})
|
|
1167
|
+
await Promise.all(approved.map((submission) => notifyTimesheetEvent(submission, 'approved')))
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
export const updateInternalApprovalMember = async (
|
|
1171
|
+
organizationId: string,
|
|
1172
|
+
actorUserId: string,
|
|
1173
|
+
submitterUserId: string,
|
|
1174
|
+
input: { required: boolean; approverUserIds: string[] }
|
|
1175
|
+
) => {
|
|
1176
|
+
const members = await listPortalOrganizationMembers(organizationId)
|
|
1177
|
+
if (!members.some((member) => member.id === submitterUserId)) {
|
|
1178
|
+
throw createError({
|
|
1179
|
+
statusCode: 404,
|
|
1180
|
+
message: 'Team member not found',
|
|
1181
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalMemberInvalid }
|
|
1182
|
+
})
|
|
1183
|
+
}
|
|
1184
|
+
const uniqueApproverIds = [...new Set(input.approverUserIds)]
|
|
1185
|
+
if (uniqueApproverIds.length !== input.approverUserIds.length) {
|
|
1186
|
+
throw createError({
|
|
1187
|
+
statusCode: 400,
|
|
1188
|
+
message: 'An approver can only be assigned once',
|
|
1189
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalDuplicateAssignment }
|
|
1190
|
+
})
|
|
1191
|
+
}
|
|
1192
|
+
if (uniqueApproverIds.includes(submitterUserId)) {
|
|
1193
|
+
throw createError({
|
|
1194
|
+
statusCode: 400,
|
|
1195
|
+
message: 'A member cannot approve their own timesheet',
|
|
1196
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalSelfAssignment }
|
|
1197
|
+
})
|
|
1198
|
+
}
|
|
1199
|
+
if (uniqueApproverIds.some((id) => !members.some((member) => member.id === id))) {
|
|
1200
|
+
throw createError({
|
|
1201
|
+
statusCode: 400,
|
|
1202
|
+
message: 'Every approver must belong to this organization',
|
|
1203
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalMemberInvalid }
|
|
1204
|
+
})
|
|
1205
|
+
}
|
|
1206
|
+
const approved = await db.transaction(async (tx) => {
|
|
1207
|
+
await tx
|
|
1208
|
+
.insert(teamMemberSettings)
|
|
1209
|
+
.values({
|
|
1210
|
+
id: nanoid(),
|
|
1211
|
+
organizationId,
|
|
1212
|
+
userId: submitterUserId,
|
|
1213
|
+
canEnterTime: true,
|
|
1214
|
+
internalApprovalRequired: input.required
|
|
1215
|
+
})
|
|
1216
|
+
.onConflictDoUpdate({
|
|
1217
|
+
target: [teamMemberSettings.organizationId, teamMemberSettings.userId],
|
|
1218
|
+
set: { internalApprovalRequired: input.required, updatedAt: new Date() }
|
|
1219
|
+
})
|
|
1220
|
+
await tx
|
|
1221
|
+
.delete(internalApproverAssignment)
|
|
1222
|
+
.where(
|
|
1223
|
+
and(
|
|
1224
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
1225
|
+
eq(internalApproverAssignment.submitterUserId, submitterUserId)
|
|
1226
|
+
)
|
|
1227
|
+
)
|
|
1228
|
+
if (input.required && uniqueApproverIds.length) {
|
|
1229
|
+
await tx.insert(internalApproverAssignment).values(
|
|
1230
|
+
uniqueApproverIds.map((approverUserId) => ({
|
|
1231
|
+
id: nanoid(),
|
|
1232
|
+
organizationId,
|
|
1233
|
+
submitterUserId,
|
|
1234
|
+
approverUserId,
|
|
1235
|
+
createdById: actorUserId
|
|
1236
|
+
}))
|
|
1237
|
+
)
|
|
1238
|
+
}
|
|
1239
|
+
if (!input.required) {
|
|
1240
|
+
return autoApprovePendingWeeks(tx, organizationId, submitterUserId)
|
|
1241
|
+
}
|
|
1242
|
+
return []
|
|
1243
|
+
})
|
|
1244
|
+
await Promise.all(approved.map((submission) => notifyTimesheetEvent(submission, 'approved')))
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
export const canMemberEnterTime = async (organizationId: string, userId: string) => {
|
|
1248
|
+
try {
|
|
1249
|
+
const [settings] = await db
|
|
1250
|
+
.select({ canEnterTime: teamMemberSettings.canEnterTime })
|
|
1251
|
+
.from(teamMemberSettings)
|
|
1252
|
+
.where(and(eq(teamMemberSettings.organizationId, organizationId), eq(teamMemberSettings.userId, userId)))
|
|
1253
|
+
.limit(1)
|
|
1254
|
+
return settings?.canEnterTime ?? true
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
if (hasDatabaseErrorCode(error, '42P01')) {
|
|
1257
|
+
return true
|
|
1258
|
+
}
|
|
1259
|
+
throw error
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const requireMemberCanEnterTime = async (organizationId: string, userId: string) => {
|
|
1264
|
+
if (await canMemberEnterTime(organizationId, userId)) {
|
|
1265
|
+
return
|
|
1266
|
+
}
|
|
1267
|
+
throw createError({
|
|
1268
|
+
statusCode: 403,
|
|
1269
|
+
message: 'Time registration is disabled for this member',
|
|
1270
|
+
data: { code: TIMESHEET_ERROR_CODES.entryDisabled }
|
|
1271
|
+
})
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
export const updateTeamMemberSettings = async (
|
|
1275
|
+
organizationId: string,
|
|
1276
|
+
userId: string,
|
|
1277
|
+
input: { canEnterTime: boolean; defaultHourlyRateMinor: number | null }
|
|
1278
|
+
) => {
|
|
1279
|
+
const member = (await listPortalOrganizationMembers(organizationId)).find((item) => item.id === userId)
|
|
1280
|
+
if (!member) {
|
|
1281
|
+
throw createError({ statusCode: 404, message: 'Team member not found' })
|
|
1282
|
+
}
|
|
1283
|
+
if (!input.canEnterTime) {
|
|
1284
|
+
const [running] = await db
|
|
1285
|
+
.select({ id: timeEntry.id })
|
|
1286
|
+
.from(timeEntry)
|
|
1287
|
+
.where(
|
|
1288
|
+
and(
|
|
1289
|
+
eq(timeEntry.organizationId, organizationId),
|
|
1290
|
+
eq(timeEntry.userId, userId),
|
|
1291
|
+
isNotNull(timeEntry.timerStartedAt)
|
|
1292
|
+
)
|
|
1293
|
+
)
|
|
1294
|
+
.limit(1)
|
|
1295
|
+
if (running) {
|
|
1296
|
+
throw createError({
|
|
1297
|
+
statusCode: 409,
|
|
1298
|
+
message: 'Stop the running timer before disabling time registration',
|
|
1299
|
+
data: { code: TIMESHEET_ERROR_CODES.runningTimer }
|
|
1300
|
+
})
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
await db.transaction(async (tx) => {
|
|
1304
|
+
await tx
|
|
1305
|
+
.insert(teamMemberSettings)
|
|
1306
|
+
.values({
|
|
1307
|
+
id: nanoid(),
|
|
1308
|
+
organizationId,
|
|
1309
|
+
userId,
|
|
1310
|
+
canEnterTime: input.canEnterTime
|
|
1311
|
+
})
|
|
1312
|
+
.onConflictDoUpdate({
|
|
1313
|
+
target: [teamMemberSettings.organizationId, teamMemberSettings.userId],
|
|
1314
|
+
set: { canEnterTime: input.canEnterTime, updatedAt: new Date() }
|
|
1315
|
+
})
|
|
1316
|
+
if (input.defaultHourlyRateMinor === null) {
|
|
1317
|
+
await tx
|
|
1318
|
+
.delete(teamTariff)
|
|
1319
|
+
.where(and(eq(teamTariff.organizationId, organizationId), eq(teamTariff.userId, userId)))
|
|
1320
|
+
} else {
|
|
1321
|
+
await tx
|
|
1322
|
+
.insert(teamTariff)
|
|
1323
|
+
.values({
|
|
1324
|
+
id: nanoid(),
|
|
1325
|
+
organizationId,
|
|
1326
|
+
userId,
|
|
1327
|
+
hourlyRateMinor: input.defaultHourlyRateMinor
|
|
1328
|
+
})
|
|
1329
|
+
.onConflictDoUpdate({
|
|
1330
|
+
target: [teamTariff.organizationId, teamTariff.userId],
|
|
1331
|
+
set: { hourlyRateMinor: input.defaultHourlyRateMinor, updatedAt: new Date() }
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
export const calculateTimesheetsSetupStatus = (
|
|
1338
|
+
clients: ClientDto[],
|
|
1339
|
+
projects: ProjectDto[],
|
|
1340
|
+
activities: ActivityTypeDto[],
|
|
1341
|
+
team: TeamMemberDto[]
|
|
1342
|
+
): TimesheetsSetupStatusDto => {
|
|
1343
|
+
const activeActivities = activities.filter((item) => item.active)
|
|
1344
|
+
const activeActivityIds = new Set(activeActivities.map((item) => item.id))
|
|
1345
|
+
const activeProjects = projects.filter((item) => item.status === 'ACTIVE')
|
|
1346
|
+
const billableActivityIds = new Set(activeActivities.filter((item) => item.billable).map((item) => item.id))
|
|
1347
|
+
const billableWorkExists = activeProjects.some((item) =>
|
|
1348
|
+
item.activityTypeIds.some((id) => billableActivityIds.has(id))
|
|
1349
|
+
)
|
|
1350
|
+
const enabledMembers = team.filter((item) => item.canEnterTime)
|
|
1351
|
+
const missingDefaultTariffCount = enabledMembers.filter((item) => item.defaultHourlyRateMinor === null).length
|
|
1352
|
+
const status = {
|
|
1353
|
+
hasClient: clients.length > 0 || activeProjects.some((item) => item.internal),
|
|
1354
|
+
hasActiveActivity: activeActivities.length > 0,
|
|
1355
|
+
hasConfiguredProject: activeProjects.some((item) => item.activityTypeIds.some((id) => activeActivityIds.has(id))),
|
|
1356
|
+
billableWorkExists,
|
|
1357
|
+
enabledMemberCount: enabledMembers.length,
|
|
1358
|
+
missingDefaultTariffCount
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
...status,
|
|
1362
|
+
complete:
|
|
1363
|
+
status.hasClient && status.hasActiveActivity && status.hasConfiguredProject && missingDefaultTariffCount === 0
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
export const getTimesheetsSetupStatus = async (organizationId: string): Promise<TimesheetsSetupStatusDto> => {
|
|
1368
|
+
const [clients, projects, activities, team] = await Promise.all([
|
|
1369
|
+
listClients(organizationId),
|
|
1370
|
+
listProjects(organizationId),
|
|
1371
|
+
listActivities(organizationId),
|
|
1372
|
+
listTeam(organizationId)
|
|
1373
|
+
])
|
|
1374
|
+
return calculateTimesheetsSetupStatus(clients, projects, activities, team)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
export const setTeamTariff = async (organizationId: string, userId: string, hourlyRateMinor: number) => {
|
|
1378
|
+
const member = (await listPortalOrganizationMembers(organizationId)).find((item) => item.id === userId)
|
|
1379
|
+
if (!member) {
|
|
1380
|
+
throw createError({ statusCode: 404, message: 'Team member not found' })
|
|
1381
|
+
}
|
|
1382
|
+
const [row] = await db
|
|
1383
|
+
.insert(teamTariff)
|
|
1384
|
+
.values({
|
|
1385
|
+
id: nanoid(),
|
|
1386
|
+
organizationId,
|
|
1387
|
+
userId,
|
|
1388
|
+
hourlyRateMinor
|
|
1389
|
+
})
|
|
1390
|
+
.onConflictDoUpdate({
|
|
1391
|
+
target: [teamTariff.organizationId, teamTariff.userId],
|
|
1392
|
+
set: { hourlyRateMinor, updatedAt: new Date() }
|
|
1393
|
+
})
|
|
1394
|
+
.returning()
|
|
1395
|
+
return row
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const hydrateProjects = async (
|
|
1399
|
+
organizationId: string,
|
|
1400
|
+
projects: Array<typeof project.$inferSelect>
|
|
1401
|
+
): Promise<ProjectDto[]> => {
|
|
1402
|
+
if (!projects.length) {
|
|
1403
|
+
return []
|
|
1404
|
+
}
|
|
1405
|
+
const ids = projects.map((item) => item.id)
|
|
1406
|
+
const [clients, providerOrganizations, assignments, rates] = await Promise.all([
|
|
1407
|
+
listClients(organizationId),
|
|
1408
|
+
db
|
|
1409
|
+
.select({ id: organization.id, name: organization.name })
|
|
1410
|
+
.from(organization)
|
|
1411
|
+
.where(eq(organization.id, organizationId)),
|
|
1412
|
+
db.select().from(projectActivity).where(inArray(projectActivity.projectId, ids)),
|
|
1413
|
+
db.select().from(projectPersonTariff).where(inArray(projectPersonTariff.projectId, ids))
|
|
1414
|
+
])
|
|
1415
|
+
const clientNames = new Map(clients.map((item) => [item.organizationId, item.name]))
|
|
1416
|
+
const providerName = providerOrganizations[0]?.name ?? 'Unknown organization'
|
|
1417
|
+
return projects.map((item) => ({
|
|
1418
|
+
id: item.id,
|
|
1419
|
+
clientOrganizationId: item.clientOrganizationId,
|
|
1420
|
+
clientName:
|
|
1421
|
+
item.clientOrganizationId === organizationId
|
|
1422
|
+
? providerName
|
|
1423
|
+
: (clientNames.get(item.clientOrganizationId) ?? 'Unknown client'),
|
|
1424
|
+
internal: item.clientOrganizationId === organizationId,
|
|
1425
|
+
name: item.name,
|
|
1426
|
+
code: item.code,
|
|
1427
|
+
status: item.status,
|
|
1428
|
+
startsOn: item.startsOn,
|
|
1429
|
+
endsOn: item.endsOn,
|
|
1430
|
+
budgetMinutes: item.budgetMinutes,
|
|
1431
|
+
budgetMinor: item.budgetMinor,
|
|
1432
|
+
activityTypeIds: assignments.filter((link) => link.projectId === item.id).map((link) => link.activityTypeId),
|
|
1433
|
+
personRates: Object.fromEntries(
|
|
1434
|
+
rates.filter((rate) => rate.projectId === item.id).map((rate) => [rate.userId, rate.hourlyRateMinor])
|
|
1435
|
+
)
|
|
1436
|
+
}))
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export const listProjects = async (organizationId: string): Promise<ProjectDto[]> => {
|
|
1440
|
+
const projects = await db
|
|
1441
|
+
.select()
|
|
1442
|
+
.from(project)
|
|
1443
|
+
.where(eq(project.organizationId, organizationId))
|
|
1444
|
+
.orderBy(desc(project.status), asc(project.name))
|
|
1445
|
+
return hydrateProjects(organizationId, projects)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
export const getProject = async (organizationId: string, id: string): Promise<ProjectDto | null> => {
|
|
1449
|
+
const projects = await db
|
|
1450
|
+
.select()
|
|
1451
|
+
.from(project)
|
|
1452
|
+
.where(and(eq(project.id, id), eq(project.organizationId, organizationId)))
|
|
1453
|
+
.limit(1)
|
|
1454
|
+
return (await hydrateProjects(organizationId, projects))[0] ?? null
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
export const createProject = async (
|
|
1458
|
+
organizationId: string,
|
|
1459
|
+
input: {
|
|
1460
|
+
clientOrganizationId: string
|
|
1461
|
+
name: string
|
|
1462
|
+
code?: string | null
|
|
1463
|
+
startsOn?: string | null
|
|
1464
|
+
endsOn?: string | null
|
|
1465
|
+
budgetMinutes?: number | null
|
|
1466
|
+
budgetMinor?: number | null
|
|
1467
|
+
activityTypeIds: string[]
|
|
1468
|
+
}
|
|
1469
|
+
) => {
|
|
1470
|
+
await ensureTimesheetClientSettings(organizationId, input.clientOrganizationId)
|
|
1471
|
+
const validActivities = await listActivities(organizationId)
|
|
1472
|
+
if (hasInvalidProjectActivityAssignments(input.activityTypeIds, validActivities)) {
|
|
1473
|
+
throw createError({ statusCode: 400, message: 'Invalid activity type assignment' })
|
|
1474
|
+
}
|
|
1475
|
+
return db.transaction(async (tx) => {
|
|
1476
|
+
const [created] = await tx
|
|
1477
|
+
.insert(project)
|
|
1478
|
+
.values({
|
|
1479
|
+
id: nanoid(),
|
|
1480
|
+
organizationId,
|
|
1481
|
+
clientOrganizationId: input.clientOrganizationId,
|
|
1482
|
+
name: input.name,
|
|
1483
|
+
code: input.code,
|
|
1484
|
+
startsOn: input.startsOn,
|
|
1485
|
+
endsOn: input.endsOn,
|
|
1486
|
+
budgetMinutes: input.budgetMinutes,
|
|
1487
|
+
budgetMinor: input.budgetMinor
|
|
1488
|
+
})
|
|
1489
|
+
.returning()
|
|
1490
|
+
if (!created) {
|
|
1491
|
+
throw createError({ statusCode: 500, message: 'Failed to create project' })
|
|
1492
|
+
}
|
|
1493
|
+
await tx.insert(projectActivity).values(
|
|
1494
|
+
input.activityTypeIds.map((activityTypeId) => ({
|
|
1495
|
+
id: nanoid(),
|
|
1496
|
+
projectId: created.id,
|
|
1497
|
+
activityTypeId
|
|
1498
|
+
}))
|
|
1499
|
+
)
|
|
1500
|
+
return created
|
|
1501
|
+
})
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
export const updateProject = async (
|
|
1505
|
+
organizationId: string,
|
|
1506
|
+
id: string,
|
|
1507
|
+
input: Record<string, unknown> & {
|
|
1508
|
+
activityTypeIds?: string[]
|
|
1509
|
+
personRates?: Record<string, number>
|
|
1510
|
+
}
|
|
1511
|
+
) => {
|
|
1512
|
+
const { activityTypeIds, personRates, ...projectValues } = input
|
|
1513
|
+
if (typeof projectValues.clientOrganizationId === 'string') {
|
|
1514
|
+
await ensureTimesheetClientSettings(organizationId, projectValues.clientOrganizationId)
|
|
1515
|
+
}
|
|
1516
|
+
return db.transaction(async (tx) => {
|
|
1517
|
+
const projectFilter = and(eq(project.id, id), eq(project.organizationId, organizationId))
|
|
1518
|
+
const [updated] =
|
|
1519
|
+
Object.keys(projectValues).length > 0
|
|
1520
|
+
? await tx.update(project).set(projectValues).where(projectFilter).returning()
|
|
1521
|
+
: await tx.select().from(project).where(projectFilter).limit(1)
|
|
1522
|
+
if (!updated) {
|
|
1523
|
+
throw createError({ statusCode: 404, message: 'Project not found' })
|
|
1524
|
+
}
|
|
1525
|
+
if (activityTypeIds) {
|
|
1526
|
+
const [valid, existingAssignments] = await Promise.all([
|
|
1527
|
+
tx
|
|
1528
|
+
.select({ id: activityType.id, active: activityType.active })
|
|
1529
|
+
.from(activityType)
|
|
1530
|
+
.where(eq(activityType.organizationId, organizationId)),
|
|
1531
|
+
tx
|
|
1532
|
+
.select({ activityTypeId: projectActivity.activityTypeId })
|
|
1533
|
+
.from(projectActivity)
|
|
1534
|
+
.where(eq(projectActivity.projectId, id))
|
|
1535
|
+
])
|
|
1536
|
+
if (
|
|
1537
|
+
hasInvalidProjectActivityAssignments(
|
|
1538
|
+
activityTypeIds,
|
|
1539
|
+
valid,
|
|
1540
|
+
existingAssignments.map((assignment) => assignment.activityTypeId)
|
|
1541
|
+
)
|
|
1542
|
+
) {
|
|
1543
|
+
throw createError({ statusCode: 400, message: 'Invalid activity type assignment' })
|
|
1544
|
+
}
|
|
1545
|
+
await tx.delete(projectActivity).where(eq(projectActivity.projectId, id))
|
|
1546
|
+
if (activityTypeIds.length) {
|
|
1547
|
+
await tx.insert(projectActivity).values(
|
|
1548
|
+
activityTypeIds.map((activityTypeId) => ({
|
|
1549
|
+
id: nanoid(),
|
|
1550
|
+
projectId: id,
|
|
1551
|
+
activityTypeId
|
|
1552
|
+
}))
|
|
1553
|
+
)
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
if (personRates) {
|
|
1557
|
+
const members = await listPortalOrganizationMembers(organizationId)
|
|
1558
|
+
if (Object.keys(personRates).some((userId) => !members.some((member) => member.id === userId))) {
|
|
1559
|
+
throw createError({ statusCode: 400, message: 'Invalid project tariff member' })
|
|
1560
|
+
}
|
|
1561
|
+
await tx.delete(projectPersonTariff).where(eq(projectPersonTariff.projectId, id))
|
|
1562
|
+
const values = Object.entries(personRates).map(([userId, hourlyRateMinor]) => ({
|
|
1563
|
+
id: nanoid(),
|
|
1564
|
+
projectId: id,
|
|
1565
|
+
userId,
|
|
1566
|
+
hourlyRateMinor
|
|
1567
|
+
}))
|
|
1568
|
+
if (values.length) {
|
|
1569
|
+
await tx.insert(projectPersonTariff).values(values)
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return updated
|
|
1573
|
+
})
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const findOwnedProject = async (organizationId: string, id: string) => {
|
|
1577
|
+
const [selectedProject] = await db
|
|
1578
|
+
.select({
|
|
1579
|
+
id: project.id,
|
|
1580
|
+
name: project.name
|
|
1581
|
+
})
|
|
1582
|
+
.from(project)
|
|
1583
|
+
.where(and(eq(project.id, id), eq(project.organizationId, organizationId)))
|
|
1584
|
+
.limit(1)
|
|
1585
|
+
if (!selectedProject) {
|
|
1586
|
+
throw createError({ statusCode: 404, message: 'Project not found' })
|
|
1587
|
+
}
|
|
1588
|
+
return selectedProject
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
export const getProjectDeletionEligibility = async (organizationId: string, id: string) => {
|
|
1592
|
+
const selectedProject = await findOwnedProject(organizationId, id)
|
|
1593
|
+
const [existingEntry] = await db
|
|
1594
|
+
.select({ id: timeEntry.id })
|
|
1595
|
+
.from(timeEntry)
|
|
1596
|
+
.where(and(eq(timeEntry.organizationId, organizationId), eq(timeEntry.projectId, id)))
|
|
1597
|
+
.limit(1)
|
|
1598
|
+
return {
|
|
1599
|
+
projectId: selectedProject.id,
|
|
1600
|
+
projectName: selectedProject.name,
|
|
1601
|
+
canDelete: !existingEntry
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
export const deleteProject = async (organizationId: string, id: string, projectName: string) =>
|
|
1606
|
+
db.transaction(async (tx) => {
|
|
1607
|
+
const [selectedProject] = await tx
|
|
1608
|
+
.select({
|
|
1609
|
+
id: project.id,
|
|
1610
|
+
name: project.name
|
|
1611
|
+
})
|
|
1612
|
+
.from(project)
|
|
1613
|
+
.where(and(eq(project.id, id), eq(project.organizationId, organizationId)))
|
|
1614
|
+
.limit(1)
|
|
1615
|
+
if (!selectedProject) {
|
|
1616
|
+
throw createError({ statusCode: 404, message: 'Project not found' })
|
|
1617
|
+
}
|
|
1618
|
+
if (selectedProject.name !== projectName) {
|
|
1619
|
+
throw createError({ statusCode: 400, message: 'Project name does not match' })
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const [existingEntry] = await tx
|
|
1623
|
+
.select({ id: timeEntry.id })
|
|
1624
|
+
.from(timeEntry)
|
|
1625
|
+
.where(and(eq(timeEntry.organizationId, organizationId), eq(timeEntry.projectId, id)))
|
|
1626
|
+
.limit(1)
|
|
1627
|
+
if (existingEntry) {
|
|
1628
|
+
throw createError({ statusCode: 409, message: 'Projects with existing time entries cannot be deleted' })
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
await tx.delete(project).where(and(eq(project.id, id), eq(project.organizationId, organizationId)))
|
|
1632
|
+
return { deleted: true }
|
|
1633
|
+
})
|
|
1634
|
+
|
|
1635
|
+
const resolveEntryContext = async (
|
|
1636
|
+
organizationId: string,
|
|
1637
|
+
userId: string,
|
|
1638
|
+
projectId: string,
|
|
1639
|
+
activityTypeId: string
|
|
1640
|
+
) => {
|
|
1641
|
+
const [settings, projects, activities, tariffs, overrides] = await Promise.all([
|
|
1642
|
+
ensureSettings(organizationId),
|
|
1643
|
+
listProjects(organizationId),
|
|
1644
|
+
listActivities(organizationId),
|
|
1645
|
+
db
|
|
1646
|
+
.select()
|
|
1647
|
+
.from(teamTariff)
|
|
1648
|
+
.where(and(eq(teamTariff.organizationId, organizationId), eq(teamTariff.userId, userId))),
|
|
1649
|
+
db
|
|
1650
|
+
.select()
|
|
1651
|
+
.from(projectPersonTariff)
|
|
1652
|
+
.where(and(eq(projectPersonTariff.projectId, projectId), eq(projectPersonTariff.userId, userId)))
|
|
1653
|
+
])
|
|
1654
|
+
const selectedProject = projects.find((item) => item.id === projectId && item.status === 'ACTIVE')
|
|
1655
|
+
const selectedActivity = activities.find((item) => item.id === activityTypeId && item.active)
|
|
1656
|
+
if (!selectedProject) {
|
|
1657
|
+
throw createError({ statusCode: 400, message: 'Project is unavailable' })
|
|
1658
|
+
}
|
|
1659
|
+
if (!selectedActivity || !selectedProject.activityTypeIds.includes(activityTypeId)) {
|
|
1660
|
+
throw createError({ statusCode: 400, message: 'Activity is unavailable for this project' })
|
|
1661
|
+
}
|
|
1662
|
+
const rate = selectedActivity.billable ? (overrides[0]?.hourlyRateMinor ?? tariffs[0]?.hourlyRateMinor) : 0
|
|
1663
|
+
if (selectedActivity.billable && rate === undefined) {
|
|
1664
|
+
throw createError({
|
|
1665
|
+
statusCode: 422,
|
|
1666
|
+
message: 'No billable tariff is configured',
|
|
1667
|
+
data: { code: TIMESHEET_ERROR_CODES.tariffRequired }
|
|
1668
|
+
})
|
|
1669
|
+
}
|
|
1670
|
+
return {
|
|
1671
|
+
clientOrganizationId: selectedProject.clientOrganizationId,
|
|
1672
|
+
billableSnapshot: selectedActivity.billable,
|
|
1673
|
+
hourlyRateMinorSnapshot: rate ?? 0,
|
|
1674
|
+
currencySnapshot: settings.currency
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const withEditableDates = async <T>(
|
|
1679
|
+
weekId: string,
|
|
1680
|
+
dates: string[],
|
|
1681
|
+
mutate: (tx: TimesheetTransaction) => Promise<T>
|
|
1682
|
+
) =>
|
|
1683
|
+
db.transaction(async (tx) => {
|
|
1684
|
+
// Serialize date checks and writes with submission of this week.
|
|
1685
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`timesheet-submit:${weekId}`}))`)
|
|
1686
|
+
const periods = await tx.select().from(timesheetSubmission).where(eq(timesheetSubmission.weeklyTimesheetId, weekId))
|
|
1687
|
+
if (dates.some((date) => isTimesheetDateLocked(date, periods))) {
|
|
1688
|
+
throw createError({
|
|
1689
|
+
statusCode: 409,
|
|
1690
|
+
message: 'Submitted or approved periods cannot be changed',
|
|
1691
|
+
data: { code: 'TIMESHEET_PERIOD_LOCKED' }
|
|
1692
|
+
})
|
|
1693
|
+
}
|
|
1694
|
+
return mutate(tx)
|
|
1695
|
+
})
|
|
1696
|
+
|
|
1697
|
+
const requireEditableEntry = async (entry: TimeEntryRecord) => {
|
|
1698
|
+
if (!entry.submissionId) {
|
|
1699
|
+
return
|
|
1700
|
+
}
|
|
1701
|
+
const [submission] = await db
|
|
1702
|
+
.select({ status: timesheetSubmission.status })
|
|
1703
|
+
.from(timesheetSubmission)
|
|
1704
|
+
.where(eq(timesheetSubmission.id, entry.submissionId))
|
|
1705
|
+
.limit(1)
|
|
1706
|
+
if (submission && !['DRAFT', 'REJECTED'].includes(submission.status)) {
|
|
1707
|
+
throw createError({ statusCode: 409, message: 'Submitted or approved time cannot be changed' })
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
export const getBootstrap = async (
|
|
1712
|
+
organizationId: string,
|
|
1713
|
+
userId: string,
|
|
1714
|
+
dateString?: string
|
|
1715
|
+
): Promise<TimesheetBootstrapDto> => {
|
|
1716
|
+
const week = await ensureWeek(organizationId, userId, dateString)
|
|
1717
|
+
const [settings, clients, projects, activities, team, entries, submissions] = await Promise.all([
|
|
1718
|
+
ensureSettings(organizationId),
|
|
1719
|
+
listClients(organizationId),
|
|
1720
|
+
listProjects(organizationId),
|
|
1721
|
+
listActivities(organizationId),
|
|
1722
|
+
listTeam(organizationId),
|
|
1723
|
+
db
|
|
1724
|
+
.select()
|
|
1725
|
+
.from(timeEntry)
|
|
1726
|
+
.where(eq(timeEntry.weeklyTimesheetId, week.id))
|
|
1727
|
+
.orderBy(asc(timeEntry.entryDate), asc(timeEntry.createdAt)),
|
|
1728
|
+
db
|
|
1729
|
+
.select()
|
|
1730
|
+
.from(timesheetSubmission)
|
|
1731
|
+
.where(eq(timesheetSubmission.weeklyTimesheetId, week.id))
|
|
1732
|
+
.orderBy(asc(timesheetSubmission.periodStartsOn), asc(timesheetSubmission.createdAt))
|
|
1733
|
+
])
|
|
1734
|
+
const reviewerIds = [...new Set(submissions.flatMap((submission) => submission.reviewedById ?? []))]
|
|
1735
|
+
const reviewerRows = reviewerIds.length
|
|
1736
|
+
? await db
|
|
1737
|
+
.select({ id: user.id, name: user.name, image: user.image })
|
|
1738
|
+
.from(user)
|
|
1739
|
+
.where(inArray(user.id, reviewerIds))
|
|
1740
|
+
: []
|
|
1741
|
+
const reviewers = new Map(reviewerRows.map((reviewer) => [reviewer.id, reviewer]))
|
|
1742
|
+
const canEnterTime = team.find((item) => item.id === userId)?.canEnterTime ?? true
|
|
1743
|
+
const setupStatus = calculateTimesheetsSetupStatus(clients, projects, activities, team)
|
|
1744
|
+
const [running] = await db
|
|
1745
|
+
.select()
|
|
1746
|
+
.from(timeEntry)
|
|
1747
|
+
.where(
|
|
1748
|
+
and(
|
|
1749
|
+
eq(timeEntry.organizationId, organizationId),
|
|
1750
|
+
eq(timeEntry.userId, userId),
|
|
1751
|
+
isNotNull(timeEntry.timerStartedAt)
|
|
1752
|
+
)
|
|
1753
|
+
)
|
|
1754
|
+
.limit(1)
|
|
1755
|
+
const [todayLocked] = await db
|
|
1756
|
+
.select({ id: timesheetSubmission.id })
|
|
1757
|
+
.from(timesheetSubmission)
|
|
1758
|
+
.where(
|
|
1759
|
+
and(
|
|
1760
|
+
eq(timesheetSubmission.organizationId, organizationId),
|
|
1761
|
+
eq(timesheetSubmission.userId, userId),
|
|
1762
|
+
inArray(timesheetSubmission.status, ['SUBMITTED', 'APPROVED']),
|
|
1763
|
+
sql`current_date between ${timesheetSubmission.periodStartsOn} and ${timesheetSubmission.periodEndsOn}`
|
|
1764
|
+
)
|
|
1765
|
+
)
|
|
1766
|
+
.limit(1)
|
|
1767
|
+
return {
|
|
1768
|
+
runningTimer: running ? toEntryDto(running) : null,
|
|
1769
|
+
canStartTimer: canEnterTime && !running && !todayLocked,
|
|
1770
|
+
settings: {
|
|
1771
|
+
currency: settings.currency,
|
|
1772
|
+
timezone: settings.timezone,
|
|
1773
|
+
timerRoundingMinutes: settings.timerRoundingMinutes,
|
|
1774
|
+
weekStartsOn: settings.weekStartsOn,
|
|
1775
|
+
internalApprovalsEnabled: settings.internalApprovalsEnabled
|
|
1776
|
+
},
|
|
1777
|
+
clients,
|
|
1778
|
+
projects,
|
|
1779
|
+
activities,
|
|
1780
|
+
team,
|
|
1781
|
+
week: toWeekDto(
|
|
1782
|
+
week,
|
|
1783
|
+
entries,
|
|
1784
|
+
submissions,
|
|
1785
|
+
reviewers,
|
|
1786
|
+
await submissionMessages(submissions.map((item) => item.id)),
|
|
1787
|
+
await clientSubmissionThreads(submissions.map((item) => item.id)),
|
|
1788
|
+
await submissionHistory(submissions.map((item) => item.id))
|
|
1789
|
+
),
|
|
1790
|
+
canEnterTime,
|
|
1791
|
+
setupStatus
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
export const createEntry = async (
|
|
1796
|
+
organizationId: string,
|
|
1797
|
+
userId: string,
|
|
1798
|
+
input: {
|
|
1799
|
+
projectId: string
|
|
1800
|
+
activityTypeId: string
|
|
1801
|
+
entryDate: string
|
|
1802
|
+
durationMinutes: number
|
|
1803
|
+
note?: string | null
|
|
1804
|
+
}
|
|
1805
|
+
) => {
|
|
1806
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
1807
|
+
const week = await ensureWeek(organizationId, userId, input.entryDate)
|
|
1808
|
+
if (input.entryDate < week.weekStartsOn || input.entryDate > addIsoDays(week.weekStartsOn, 6)) {
|
|
1809
|
+
throw createError({ statusCode: 400, message: 'Entry date is outside the selected week' })
|
|
1810
|
+
}
|
|
1811
|
+
const snapshots = await resolveEntryContext(organizationId, userId, input.projectId, input.activityTypeId)
|
|
1812
|
+
const [created] = await withEditableDates(week.id, [input.entryDate], (tx) =>
|
|
1813
|
+
tx
|
|
1814
|
+
.insert(timeEntry)
|
|
1815
|
+
.values({
|
|
1816
|
+
id: nanoid(),
|
|
1817
|
+
organizationId,
|
|
1818
|
+
weeklyTimesheetId: week.id,
|
|
1819
|
+
userId,
|
|
1820
|
+
...input,
|
|
1821
|
+
...snapshots
|
|
1822
|
+
})
|
|
1823
|
+
.returning()
|
|
1824
|
+
)
|
|
1825
|
+
if (!created) {
|
|
1826
|
+
throw createError({ statusCode: 500, message: 'Failed to create entry' })
|
|
1827
|
+
}
|
|
1828
|
+
return toEntryDto(created)
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
export const updateEntry = async (
|
|
1832
|
+
organizationId: string,
|
|
1833
|
+
userId: string,
|
|
1834
|
+
id: string,
|
|
1835
|
+
input: Partial<{
|
|
1836
|
+
projectId: string
|
|
1837
|
+
activityTypeId: string
|
|
1838
|
+
entryDate: string
|
|
1839
|
+
durationMinutes: number
|
|
1840
|
+
note: string | null
|
|
1841
|
+
}>
|
|
1842
|
+
) => {
|
|
1843
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
1844
|
+
const [current] = await db
|
|
1845
|
+
.select()
|
|
1846
|
+
.from(timeEntry)
|
|
1847
|
+
.where(and(eq(timeEntry.id, id), eq(timeEntry.organizationId, organizationId), eq(timeEntry.userId, userId)))
|
|
1848
|
+
.limit(1)
|
|
1849
|
+
if (!current) {
|
|
1850
|
+
throw createError({ statusCode: 404, message: 'Time entry not found' })
|
|
1851
|
+
}
|
|
1852
|
+
const [week] = await db
|
|
1853
|
+
.select()
|
|
1854
|
+
.from(weeklyTimesheet)
|
|
1855
|
+
.where(eq(weeklyTimesheet.id, current.weeklyTimesheetId))
|
|
1856
|
+
.limit(1)
|
|
1857
|
+
if (!week) {
|
|
1858
|
+
throw createError({ statusCode: 404, message: 'Timesheet week not found' })
|
|
1859
|
+
}
|
|
1860
|
+
await requireEditableEntry(current)
|
|
1861
|
+
if (current.timerStartedAt) {
|
|
1862
|
+
throw createError({ statusCode: 409, message: 'Stop the timer before editing' })
|
|
1863
|
+
}
|
|
1864
|
+
const projectId = input.projectId ?? current.projectId
|
|
1865
|
+
const activityTypeId = input.activityTypeId ?? current.activityTypeId
|
|
1866
|
+
const entryDate = input.entryDate ?? current.entryDate
|
|
1867
|
+
if (entryDate < week.weekStartsOn || entryDate > addIsoDays(week.weekStartsOn, 6)) {
|
|
1868
|
+
throw createError({ statusCode: 400, message: 'Entry date is outside the selected week' })
|
|
1869
|
+
}
|
|
1870
|
+
const snapshots = await resolveEntryContext(organizationId, userId, projectId, activityTypeId)
|
|
1871
|
+
const [updated] = await withEditableDates(week.id, [current.entryDate, entryDate], (tx) =>
|
|
1872
|
+
tx
|
|
1873
|
+
.update(timeEntry)
|
|
1874
|
+
.set({ ...input, ...snapshots })
|
|
1875
|
+
.where(eq(timeEntry.id, id))
|
|
1876
|
+
.returning()
|
|
1877
|
+
)
|
|
1878
|
+
if (!updated) {
|
|
1879
|
+
throw createError({ statusCode: 500, message: 'Failed to update entry' })
|
|
1880
|
+
}
|
|
1881
|
+
return toEntryDto(updated)
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
export const deleteEntry = async (organizationId: string, userId: string, id: string) => {
|
|
1885
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
1886
|
+
const [current] = await db
|
|
1887
|
+
.select()
|
|
1888
|
+
.from(timeEntry)
|
|
1889
|
+
.where(and(eq(timeEntry.id, id), eq(timeEntry.organizationId, organizationId), eq(timeEntry.userId, userId)))
|
|
1890
|
+
.limit(1)
|
|
1891
|
+
if (!current) {
|
|
1892
|
+
throw createError({ statusCode: 404, message: 'Time entry not found' })
|
|
1893
|
+
}
|
|
1894
|
+
const [week] = await db
|
|
1895
|
+
.select()
|
|
1896
|
+
.from(weeklyTimesheet)
|
|
1897
|
+
.where(eq(weeklyTimesheet.id, current.weeklyTimesheetId))
|
|
1898
|
+
.limit(1)
|
|
1899
|
+
if (!week) {
|
|
1900
|
+
throw createError({ statusCode: 404, message: 'Timesheet week not found' })
|
|
1901
|
+
}
|
|
1902
|
+
await requireEditableEntry(current)
|
|
1903
|
+
await withEditableDates(week.id, [current.entryDate], (tx) => tx.delete(timeEntry).where(eq(timeEntry.id, id)))
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
export const startTimer = async (
|
|
1907
|
+
organizationId: string,
|
|
1908
|
+
userId: string,
|
|
1909
|
+
input: { projectId: string; activityTypeId: string; entryDate: string; note?: string | null }
|
|
1910
|
+
) => {
|
|
1911
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
1912
|
+
const [running] = await db
|
|
1913
|
+
.select()
|
|
1914
|
+
.from(timeEntry)
|
|
1915
|
+
.where(
|
|
1916
|
+
and(
|
|
1917
|
+
eq(timeEntry.organizationId, organizationId),
|
|
1918
|
+
eq(timeEntry.userId, userId),
|
|
1919
|
+
isNotNull(timeEntry.timerStartedAt)
|
|
1920
|
+
)
|
|
1921
|
+
)
|
|
1922
|
+
.limit(1)
|
|
1923
|
+
if (running) {
|
|
1924
|
+
throw createError({ statusCode: 409, message: 'Another timer is already running' })
|
|
1925
|
+
}
|
|
1926
|
+
const week = await ensureWeek(organizationId, userId, input.entryDate)
|
|
1927
|
+
const snapshots = await resolveEntryContext(organizationId, userId, input.projectId, input.activityTypeId)
|
|
1928
|
+
const [created] = await withEditableDates(week.id, [input.entryDate], async (tx) => {
|
|
1929
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${'timesheet-timer:' + organizationId + ':' + userId}))`)
|
|
1930
|
+
const [active] = await tx
|
|
1931
|
+
.select()
|
|
1932
|
+
.from(timeEntry)
|
|
1933
|
+
.where(
|
|
1934
|
+
and(
|
|
1935
|
+
eq(timeEntry.organizationId, organizationId),
|
|
1936
|
+
eq(timeEntry.userId, userId),
|
|
1937
|
+
isNotNull(timeEntry.timerStartedAt)
|
|
1938
|
+
)
|
|
1939
|
+
)
|
|
1940
|
+
.limit(1)
|
|
1941
|
+
if (active) {
|
|
1942
|
+
throw createError({
|
|
1943
|
+
statusCode: 409,
|
|
1944
|
+
message: 'Another timer is already running',
|
|
1945
|
+
data: { code: TIMESHEET_ERROR_CODES.runningTimer }
|
|
1946
|
+
})
|
|
1947
|
+
}
|
|
1948
|
+
return tx
|
|
1949
|
+
.insert(timeEntry)
|
|
1950
|
+
.values({
|
|
1951
|
+
id: nanoid(),
|
|
1952
|
+
organizationId,
|
|
1953
|
+
weeklyTimesheetId: week.id,
|
|
1954
|
+
userId,
|
|
1955
|
+
...input,
|
|
1956
|
+
durationMinutes: 0,
|
|
1957
|
+
timerStartedAt: new Date(),
|
|
1958
|
+
...snapshots
|
|
1959
|
+
})
|
|
1960
|
+
.returning()
|
|
1961
|
+
})
|
|
1962
|
+
if (!created) {
|
|
1963
|
+
throw createError({ statusCode: 500, message: 'Failed to start timer' })
|
|
1964
|
+
}
|
|
1965
|
+
return toEntryDto(created)
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
export const stopTimer = async (organizationId: string, userId: string) => {
|
|
1969
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
1970
|
+
const [running] = await db
|
|
1971
|
+
.select()
|
|
1972
|
+
.from(timeEntry)
|
|
1973
|
+
.where(
|
|
1974
|
+
and(
|
|
1975
|
+
eq(timeEntry.organizationId, organizationId),
|
|
1976
|
+
eq(timeEntry.userId, userId),
|
|
1977
|
+
isNotNull(timeEntry.timerStartedAt)
|
|
1978
|
+
)
|
|
1979
|
+
)
|
|
1980
|
+
.limit(1)
|
|
1981
|
+
if (!running?.timerStartedAt) {
|
|
1982
|
+
throw createError({ statusCode: 404, message: 'No timer is running' })
|
|
1983
|
+
}
|
|
1984
|
+
const settings = await ensureSettings(organizationId)
|
|
1985
|
+
const elapsed = roundTimerMinutes(Date.now() - running.timerStartedAt.getTime(), settings.timerRoundingMinutes)
|
|
1986
|
+
const [updated] = await withEditableDates(running.weeklyTimesheetId, [running.entryDate], (tx) =>
|
|
1987
|
+
tx
|
|
1988
|
+
.update(timeEntry)
|
|
1989
|
+
.set({
|
|
1990
|
+
durationMinutes: elapsed,
|
|
1991
|
+
timerStartedAt: null
|
|
1992
|
+
})
|
|
1993
|
+
.where(eq(timeEntry.id, running.id))
|
|
1994
|
+
.returning()
|
|
1995
|
+
)
|
|
1996
|
+
if (!updated) {
|
|
1997
|
+
throw createError({ statusCode: 500, message: 'Failed to stop timer' })
|
|
1998
|
+
}
|
|
1999
|
+
return toEntryDto(updated)
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
export const submitWeek = async (
|
|
2003
|
+
organizationId: string,
|
|
2004
|
+
userId: string,
|
|
2005
|
+
weekId: string,
|
|
2006
|
+
cutoffDate: string,
|
|
2007
|
+
comment?: string
|
|
2008
|
+
) => {
|
|
2009
|
+
const result = await db.transaction(async (tx) => {
|
|
2010
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
2011
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`timesheet-submit:${weekId}`}))`)
|
|
2012
|
+
const [week] = await tx
|
|
2013
|
+
.select()
|
|
2014
|
+
.from(weeklyTimesheet)
|
|
2015
|
+
.where(
|
|
2016
|
+
and(
|
|
2017
|
+
eq(weeklyTimesheet.id, weekId),
|
|
2018
|
+
eq(weeklyTimesheet.organizationId, organizationId),
|
|
2019
|
+
eq(weeklyTimesheet.userId, userId)
|
|
2020
|
+
)
|
|
2021
|
+
)
|
|
2022
|
+
.limit(1)
|
|
2023
|
+
if (!week) {
|
|
2024
|
+
throw createError({ statusCode: 404, message: 'Timesheet week not found' })
|
|
2025
|
+
}
|
|
2026
|
+
const [submissionSettings] = await tx
|
|
2027
|
+
.select({ timezone: workspaceSettings.timezone })
|
|
2028
|
+
.from(workspaceSettings)
|
|
2029
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
2030
|
+
.limit(1)
|
|
2031
|
+
const today = currentIsoDateInTimezone(submissionSettings?.timezone ?? 'UTC')
|
|
2032
|
+
if (cutoffDate < week.weekStartsOn || cutoffDate > addIsoDays(week.weekStartsOn, 6) || cutoffDate > today) {
|
|
2033
|
+
throw createError({
|
|
2034
|
+
statusCode: 400,
|
|
2035
|
+
message: 'Submission cutoff must be within the selected week and not in the future'
|
|
2036
|
+
})
|
|
2037
|
+
}
|
|
2038
|
+
const entries = await tx
|
|
2039
|
+
.select()
|
|
2040
|
+
.from(timeEntry)
|
|
2041
|
+
.where(
|
|
2042
|
+
and(
|
|
2043
|
+
eq(timeEntry.weeklyTimesheetId, week.id),
|
|
2044
|
+
isNull(timeEntry.submissionId),
|
|
2045
|
+
lte(timeEntry.entryDate, cutoffDate)
|
|
2046
|
+
)
|
|
2047
|
+
)
|
|
2048
|
+
.orderBy(asc(timeEntry.entryDate), asc(timeEntry.createdAt))
|
|
2049
|
+
if (!entries.length) {
|
|
2050
|
+
throw createError({ statusCode: 422, message: 'An empty week cannot be submitted' })
|
|
2051
|
+
}
|
|
2052
|
+
if (entries.some((entry) => entry.timerStartedAt)) {
|
|
2053
|
+
throw createError({ statusCode: 409, message: 'Stop the running timer before submitting' })
|
|
2054
|
+
}
|
|
2055
|
+
const workspace = await tx
|
|
2056
|
+
.select({ enabled: workspaceSettings.internalApprovalsEnabled })
|
|
2057
|
+
.from(workspaceSettings)
|
|
2058
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
2059
|
+
.limit(1)
|
|
2060
|
+
const memberSetting = await tx
|
|
2061
|
+
.select({ required: teamMemberSettings.internalApprovalRequired })
|
|
2062
|
+
.from(teamMemberSettings)
|
|
2063
|
+
.where(and(eq(teamMemberSettings.organizationId, organizationId), eq(teamMemberSettings.userId, userId)))
|
|
2064
|
+
.limit(1)
|
|
2065
|
+
const assignment = await tx
|
|
2066
|
+
.select({ id: internalApproverAssignment.id })
|
|
2067
|
+
.from(internalApproverAssignment)
|
|
2068
|
+
.where(
|
|
2069
|
+
and(
|
|
2070
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
2071
|
+
eq(internalApproverAssignment.submitterUserId, userId)
|
|
2072
|
+
)
|
|
2073
|
+
)
|
|
2074
|
+
.limit(1)
|
|
2075
|
+
const requiresApproval = (workspace[0]?.enabled ?? true) && (memberSetting[0]?.required ?? true)
|
|
2076
|
+
if (requiresApproval && !assignment.length) {
|
|
2077
|
+
throw createError({
|
|
2078
|
+
statusCode: 409,
|
|
2079
|
+
message: 'No internal approver is configured for this member',
|
|
2080
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApproverRequired }
|
|
2081
|
+
})
|
|
2082
|
+
}
|
|
2083
|
+
const now = new Date()
|
|
2084
|
+
const submissionId = nanoid()
|
|
2085
|
+
const [created] = await tx
|
|
2086
|
+
.insert(timesheetSubmission)
|
|
2087
|
+
.values({
|
|
2088
|
+
id: submissionId,
|
|
2089
|
+
weeklyTimesheetId: week.id,
|
|
2090
|
+
organizationId,
|
|
2091
|
+
userId,
|
|
2092
|
+
periodStartsOn: entries[0]!.entryDate,
|
|
2093
|
+
periodEndsOn: cutoffDate,
|
|
2094
|
+
status: requiresApproval ? 'SUBMITTED' : 'APPROVED',
|
|
2095
|
+
submittedAt: now,
|
|
2096
|
+
reviewedAt: requiresApproval ? null : now
|
|
2097
|
+
})
|
|
2098
|
+
.returning()
|
|
2099
|
+
await tx
|
|
2100
|
+
.update(timeEntry)
|
|
2101
|
+
.set({ submissionId })
|
|
2102
|
+
.where(
|
|
2103
|
+
inArray(
|
|
2104
|
+
timeEntry.id,
|
|
2105
|
+
entries.map((entry) => entry.id)
|
|
2106
|
+
)
|
|
2107
|
+
)
|
|
2108
|
+
await tx.insert(timesheetApprovalHistory).values({
|
|
2109
|
+
id: nanoid(),
|
|
2110
|
+
weeklyTimesheetId: week.id,
|
|
2111
|
+
submissionId,
|
|
2112
|
+
action: 'SUBMITTED',
|
|
2113
|
+
actorUserId: userId,
|
|
2114
|
+
comment: comment?.trim() || null
|
|
2115
|
+
})
|
|
2116
|
+
if (!requiresApproval) {
|
|
2117
|
+
await tx.insert(timesheetApprovalHistory).values({
|
|
2118
|
+
id: nanoid(),
|
|
2119
|
+
weeklyTimesheetId: week.id,
|
|
2120
|
+
submissionId,
|
|
2121
|
+
action: 'APPROVED',
|
|
2122
|
+
actorUserId: userId,
|
|
2123
|
+
comment: 'Automatically approved because internal approval is disabled'
|
|
2124
|
+
})
|
|
2125
|
+
const representedClients = await tx
|
|
2126
|
+
.selectDistinct({ clientOrganizationId: timeEntry.clientOrganizationId })
|
|
2127
|
+
.from(timeEntry)
|
|
2128
|
+
.innerJoin(
|
|
2129
|
+
workspaceClient,
|
|
2130
|
+
and(
|
|
2131
|
+
eq(workspaceClient.workspaceOrganizationId, organizationId),
|
|
2132
|
+
eq(workspaceClient.clientOrganizationId, timeEntry.clientOrganizationId),
|
|
2133
|
+
eq(workspaceClient.accessMode, 'REVIEW')
|
|
2134
|
+
)
|
|
2135
|
+
)
|
|
2136
|
+
.where(eq(timeEntry.submissionId, submissionId))
|
|
2137
|
+
if (representedClients.length) {
|
|
2138
|
+
await tx
|
|
2139
|
+
.insert(timesheetClientReview)
|
|
2140
|
+
.values(
|
|
2141
|
+
representedClients.map((item) => ({
|
|
2142
|
+
id: nanoid(),
|
|
2143
|
+
weeklyTimesheetId: week.id,
|
|
2144
|
+
submissionId,
|
|
2145
|
+
clientOrganizationId: item.clientOrganizationId,
|
|
2146
|
+
status: 'PENDING' as const
|
|
2147
|
+
}))
|
|
2148
|
+
)
|
|
2149
|
+
.onConflictDoNothing()
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
return created
|
|
2153
|
+
})
|
|
2154
|
+
await notifyTimesheetEvent(result, 'submitted', undefined, comment)
|
|
2155
|
+
return result
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
export const resubmitSubmission = async (
|
|
2159
|
+
organizationId: string,
|
|
2160
|
+
userId: string,
|
|
2161
|
+
submissionId: string,
|
|
2162
|
+
reply?: string
|
|
2163
|
+
) => {
|
|
2164
|
+
const result = await db.transaction(async (tx) => {
|
|
2165
|
+
await requireMemberCanEnterTime(organizationId, userId)
|
|
2166
|
+
const [submission] = await tx
|
|
2167
|
+
.select()
|
|
2168
|
+
.from(timesheetSubmission)
|
|
2169
|
+
.where(
|
|
2170
|
+
and(
|
|
2171
|
+
eq(timesheetSubmission.id, submissionId),
|
|
2172
|
+
eq(timesheetSubmission.organizationId, organizationId),
|
|
2173
|
+
eq(timesheetSubmission.userId, userId),
|
|
2174
|
+
inArray(timesheetSubmission.status, ['DRAFT', 'REJECTED'])
|
|
2175
|
+
)
|
|
2176
|
+
)
|
|
2177
|
+
.limit(1)
|
|
2178
|
+
if (!submission) {
|
|
2179
|
+
throw createError({ statusCode: 404, message: 'Editable submission not found' })
|
|
2180
|
+
}
|
|
2181
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`timesheet-submit:${submission.weeklyTimesheetId}`}))`)
|
|
2182
|
+
const entries = await tx.select().from(timeEntry).where(eq(timeEntry.submissionId, submission.id))
|
|
2183
|
+
if (!entries.length) {
|
|
2184
|
+
throw createError({ statusCode: 422, message: 'An empty submission cannot be submitted' })
|
|
2185
|
+
}
|
|
2186
|
+
if (entries.some((entry) => entry.timerStartedAt)) {
|
|
2187
|
+
throw createError({ statusCode: 409, message: 'Stop the running timer before submitting' })
|
|
2188
|
+
}
|
|
2189
|
+
const workspace = await tx
|
|
2190
|
+
.select({ enabled: workspaceSettings.internalApprovalsEnabled })
|
|
2191
|
+
.from(workspaceSettings)
|
|
2192
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
2193
|
+
.limit(1)
|
|
2194
|
+
const memberSetting = await tx
|
|
2195
|
+
.select({ required: teamMemberSettings.internalApprovalRequired })
|
|
2196
|
+
.from(teamMemberSettings)
|
|
2197
|
+
.where(and(eq(teamMemberSettings.organizationId, organizationId), eq(teamMemberSettings.userId, userId)))
|
|
2198
|
+
.limit(1)
|
|
2199
|
+
const assignment = await tx
|
|
2200
|
+
.select({ id: internalApproverAssignment.id })
|
|
2201
|
+
.from(internalApproverAssignment)
|
|
2202
|
+
.where(
|
|
2203
|
+
and(
|
|
2204
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
2205
|
+
eq(internalApproverAssignment.submitterUserId, userId)
|
|
2206
|
+
)
|
|
2207
|
+
)
|
|
2208
|
+
.limit(1)
|
|
2209
|
+
const requiresApproval = (workspace[0]?.enabled ?? true) && (memberSetting[0]?.required ?? true)
|
|
2210
|
+
if (requiresApproval && !assignment.length) {
|
|
2211
|
+
throw createError({
|
|
2212
|
+
statusCode: 409,
|
|
2213
|
+
message: 'No internal approver is configured for this member',
|
|
2214
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApproverRequired }
|
|
2215
|
+
})
|
|
2216
|
+
}
|
|
2217
|
+
const now = new Date()
|
|
2218
|
+
const entryDates = entries.map((entry) => entry.entryDate).sort()
|
|
2219
|
+
const [updated] = await tx
|
|
2220
|
+
.update(timesheetSubmission)
|
|
2221
|
+
.set({
|
|
2222
|
+
status: requiresApproval ? 'SUBMITTED' : 'APPROVED',
|
|
2223
|
+
submittedAt: now,
|
|
2224
|
+
reviewedAt: requiresApproval ? null : now,
|
|
2225
|
+
reviewedById: null,
|
|
2226
|
+
rejectionComment: null,
|
|
2227
|
+
periodStartsOn: entryDates[0]!,
|
|
2228
|
+
periodEndsOn: entryDates.at(-1)!,
|
|
2229
|
+
version: sql`${timesheetSubmission.version} + 1`
|
|
2230
|
+
})
|
|
2231
|
+
.where(eq(timesheetSubmission.id, submission.id))
|
|
2232
|
+
.returning()
|
|
2233
|
+
await tx.insert(timesheetApprovalHistory).values({
|
|
2234
|
+
id: nanoid(),
|
|
2235
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
2236
|
+
submissionId: submission.id,
|
|
2237
|
+
action: 'SUBMITTED',
|
|
2238
|
+
actorUserId: userId,
|
|
2239
|
+
comment: reply?.trim() || null
|
|
2240
|
+
})
|
|
2241
|
+
if (!requiresApproval) {
|
|
2242
|
+
await tx.insert(timesheetApprovalHistory).values({
|
|
2243
|
+
id: nanoid(),
|
|
2244
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
2245
|
+
submissionId: submission.id,
|
|
2246
|
+
action: 'APPROVED',
|
|
2247
|
+
actorUserId: userId,
|
|
2248
|
+
comment: 'Automatically approved because internal approval is disabled'
|
|
2249
|
+
})
|
|
2250
|
+
const representedClients = await tx
|
|
2251
|
+
.selectDistinct({ clientOrganizationId: timeEntry.clientOrganizationId })
|
|
2252
|
+
.from(timeEntry)
|
|
2253
|
+
.innerJoin(
|
|
2254
|
+
workspaceClient,
|
|
2255
|
+
and(
|
|
2256
|
+
eq(workspaceClient.workspaceOrganizationId, organizationId),
|
|
2257
|
+
eq(workspaceClient.clientOrganizationId, timeEntry.clientOrganizationId),
|
|
2258
|
+
eq(workspaceClient.accessMode, 'REVIEW')
|
|
2259
|
+
)
|
|
2260
|
+
)
|
|
2261
|
+
.where(eq(timeEntry.submissionId, submission.id))
|
|
2262
|
+
if (representedClients.length) {
|
|
2263
|
+
await tx
|
|
2264
|
+
.insert(timesheetClientReview)
|
|
2265
|
+
.values(
|
|
2266
|
+
representedClients.map((item) => ({
|
|
2267
|
+
id: nanoid(),
|
|
2268
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
2269
|
+
submissionId: submission.id,
|
|
2270
|
+
clientOrganizationId: item.clientOrganizationId,
|
|
2271
|
+
status: 'PENDING' as const
|
|
2272
|
+
}))
|
|
2273
|
+
)
|
|
2274
|
+
.onConflictDoNothing()
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return updated
|
|
2278
|
+
})
|
|
2279
|
+
await notifyTimesheetEvent(result, 'submitted', undefined, reply)
|
|
2280
|
+
return result
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
export const listInternalApprovalMembers = (organizationId: string, approverUserId: string) =>
|
|
2284
|
+
db
|
|
2285
|
+
.selectDistinct({ id: user.id, name: user.name })
|
|
2286
|
+
.from(internalApproverAssignment)
|
|
2287
|
+
.innerJoin(user, eq(user.id, internalApproverAssignment.submitterUserId))
|
|
2288
|
+
.where(
|
|
2289
|
+
and(
|
|
2290
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
2291
|
+
eq(internalApproverAssignment.approverUserId, approverUserId)
|
|
2292
|
+
)
|
|
2293
|
+
)
|
|
2294
|
+
.orderBy(asc(user.name), asc(user.id))
|
|
2295
|
+
|
|
2296
|
+
export const listApprovalQueue = async (
|
|
2297
|
+
organizationId: string,
|
|
2298
|
+
approverUserId: string,
|
|
2299
|
+
scope: 'assigned' | 'provider' = 'assigned'
|
|
2300
|
+
): Promise<ApprovalQueueItemDto[]> => {
|
|
2301
|
+
const organizationMembers = await listPortalOrganizationMembers(organizationId)
|
|
2302
|
+
const isAdmin = organizationMembers.some(
|
|
2303
|
+
(member) => member.id === approverUserId && ['owner', 'admin'].includes(member.organizationRole)
|
|
2304
|
+
)
|
|
2305
|
+
const [workspace] = await db
|
|
2306
|
+
.select({ enabled: workspaceSettings.internalApprovalsEnabled })
|
|
2307
|
+
.from(workspaceSettings)
|
|
2308
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
2309
|
+
.limit(1)
|
|
2310
|
+
if (!workspace?.enabled && !(scope === 'provider' && isAdmin)) {
|
|
2311
|
+
return []
|
|
2312
|
+
}
|
|
2313
|
+
const assignments = await db
|
|
2314
|
+
.select({ submitterUserId: internalApproverAssignment.submitterUserId })
|
|
2315
|
+
.from(internalApproverAssignment)
|
|
2316
|
+
.where(
|
|
2317
|
+
and(
|
|
2318
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
2319
|
+
eq(internalApproverAssignment.approverUserId, approverUserId)
|
|
2320
|
+
)
|
|
2321
|
+
)
|
|
2322
|
+
const submitterIds =
|
|
2323
|
+
scope === 'provider' && isAdmin
|
|
2324
|
+
? organizationMembers.map((item) => item.id)
|
|
2325
|
+
: assignments.map((item) => item.submitterUserId)
|
|
2326
|
+
if (!submitterIds.length) {
|
|
2327
|
+
return []
|
|
2328
|
+
}
|
|
2329
|
+
const [submissions, members, settings] = await Promise.all([
|
|
2330
|
+
db
|
|
2331
|
+
.select({ submission: timesheetSubmission, weekStartsOn: weeklyTimesheet.weekStartsOn })
|
|
2332
|
+
.from(timesheetSubmission)
|
|
2333
|
+
.innerJoin(weeklyTimesheet, eq(weeklyTimesheet.id, timesheetSubmission.weeklyTimesheetId))
|
|
2334
|
+
.where(
|
|
2335
|
+
and(
|
|
2336
|
+
eq(timesheetSubmission.organizationId, organizationId),
|
|
2337
|
+
inArray(timesheetSubmission.userId, submitterIds),
|
|
2338
|
+
inArray(timesheetSubmission.status, ['SUBMITTED', 'APPROVED', 'REJECTED'])
|
|
2339
|
+
)
|
|
2340
|
+
)
|
|
2341
|
+
.orderBy(desc(timesheetSubmission.periodEndsOn)),
|
|
2342
|
+
listPortalOrganizationMembers(organizationId),
|
|
2343
|
+
ensureSettings(organizationId)
|
|
2344
|
+
])
|
|
2345
|
+
if (!submissions.length) {
|
|
2346
|
+
return []
|
|
2347
|
+
}
|
|
2348
|
+
const entries = await db
|
|
2349
|
+
.select()
|
|
2350
|
+
.from(timeEntry)
|
|
2351
|
+
.where(
|
|
2352
|
+
inArray(
|
|
2353
|
+
timeEntry.submissionId,
|
|
2354
|
+
submissions.map((item) => item.submission.id)
|
|
2355
|
+
)
|
|
2356
|
+
)
|
|
2357
|
+
.orderBy(asc(timeEntry.entryDate), asc(timeEntry.createdAt))
|
|
2358
|
+
const clientReviews = await db
|
|
2359
|
+
.select()
|
|
2360
|
+
.from(timesheetClientReview)
|
|
2361
|
+
.where(
|
|
2362
|
+
inArray(
|
|
2363
|
+
timesheetClientReview.submissionId,
|
|
2364
|
+
submissions.map((item) => item.submission.id)
|
|
2365
|
+
)
|
|
2366
|
+
)
|
|
2367
|
+
const history = await submissionHistory(submissions.map((item) => item.submission.id))
|
|
2368
|
+
const messages = await submissionMessages(submissions.map((item) => item.submission.id))
|
|
2369
|
+
const names = new Map(members.map((member) => [member.id, member.name]))
|
|
2370
|
+
return submissions.map(({ submission, weekStartsOn }) => {
|
|
2371
|
+
const submissionEntries = entries.filter((entry) => entry.submissionId === submission.id)
|
|
2372
|
+
return {
|
|
2373
|
+
id: submission.id,
|
|
2374
|
+
userId: submission.userId,
|
|
2375
|
+
history: history.filter(
|
|
2376
|
+
(event) => event.submissionId === submission.id && (isAdmin || !event.clientOrganizationId)
|
|
2377
|
+
),
|
|
2378
|
+
messages: messages.filter((message) => message.submissionId === submission.id),
|
|
2379
|
+
userName: names.get(submission.userId) ?? 'Unknown member',
|
|
2380
|
+
weekStartsOn,
|
|
2381
|
+
periodStartsOn: submission.periodStartsOn,
|
|
2382
|
+
periodEndsOn: submission.periodEndsOn,
|
|
2383
|
+
status: submission.status,
|
|
2384
|
+
totalMinutes: submissionEntries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
|
|
2385
|
+
billableMinutes: submissionEntries
|
|
2386
|
+
.filter((entry) => entry.billableSnapshot)
|
|
2387
|
+
.reduce((sum, entry) => sum + entry.durationMinutes, 0),
|
|
2388
|
+
billableAmountMinor: submissionEntries.reduce(
|
|
2389
|
+
(sum, entry) => sum + Math.round((entry.durationMinutes * entry.hourlyRateMinorSnapshot) / 60),
|
|
2390
|
+
0
|
|
2391
|
+
),
|
|
2392
|
+
currency: settings.currency,
|
|
2393
|
+
submittedAt: submission.submittedAt?.toISOString() ?? null,
|
|
2394
|
+
entries: submissionEntries.map((entry) => toEntryDto(entry, submission.status)),
|
|
2395
|
+
clientReviews: clientReviews
|
|
2396
|
+
.filter((review) => review.submissionId === submission.id)
|
|
2397
|
+
.map((review) => ({
|
|
2398
|
+
clientOrganizationId: review.clientOrganizationId,
|
|
2399
|
+
status: review.status,
|
|
2400
|
+
version: review.version,
|
|
2401
|
+
canReply: isAdmin,
|
|
2402
|
+
createdAt: review.createdAt.toISOString(),
|
|
2403
|
+
comment: review.comment
|
|
2404
|
+
}))
|
|
2405
|
+
}
|
|
2406
|
+
})
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
export const reviewSubmission = async (
|
|
2410
|
+
organizationId: string,
|
|
2411
|
+
actorUserId: string,
|
|
2412
|
+
submissionId: string,
|
|
2413
|
+
action: 'APPROVE' | 'REJECT' | 'REOPEN',
|
|
2414
|
+
comment?: string | null
|
|
2415
|
+
) => {
|
|
2416
|
+
const result = await db.transaction(async (tx) => {
|
|
2417
|
+
const [workspace] = await tx
|
|
2418
|
+
.select({ enabled: workspaceSettings.internalApprovalsEnabled })
|
|
2419
|
+
.from(workspaceSettings)
|
|
2420
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
2421
|
+
.limit(1)
|
|
2422
|
+
if (!workspace?.enabled) {
|
|
2423
|
+
throw createError({
|
|
2424
|
+
statusCode: 403,
|
|
2425
|
+
message: 'Internal approvals are disabled',
|
|
2426
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalUnauthorized }
|
|
2427
|
+
})
|
|
2428
|
+
}
|
|
2429
|
+
const [submission] = await tx
|
|
2430
|
+
.select()
|
|
2431
|
+
.from(timesheetSubmission)
|
|
2432
|
+
.where(and(eq(timesheetSubmission.id, submissionId), eq(timesheetSubmission.organizationId, organizationId)))
|
|
2433
|
+
.limit(1)
|
|
2434
|
+
if (!submission) {
|
|
2435
|
+
throw createError({ statusCode: 404, message: 'Timesheet submission not found' })
|
|
2436
|
+
}
|
|
2437
|
+
const [assignment] = await tx
|
|
2438
|
+
.select({ id: internalApproverAssignment.id })
|
|
2439
|
+
.from(internalApproverAssignment)
|
|
2440
|
+
.where(
|
|
2441
|
+
and(
|
|
2442
|
+
eq(internalApproverAssignment.organizationId, organizationId),
|
|
2443
|
+
eq(internalApproverAssignment.submitterUserId, submission.userId),
|
|
2444
|
+
eq(internalApproverAssignment.approverUserId, actorUserId)
|
|
2445
|
+
)
|
|
2446
|
+
)
|
|
2447
|
+
.limit(1)
|
|
2448
|
+
if (!assignment) {
|
|
2449
|
+
throw createError({
|
|
2450
|
+
statusCode: 403,
|
|
2451
|
+
message: 'You are not assigned to approve this member',
|
|
2452
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalUnauthorized }
|
|
2453
|
+
})
|
|
2454
|
+
}
|
|
2455
|
+
if (action !== 'REOPEN' && submission.status !== 'SUBMITTED') {
|
|
2456
|
+
throw createError({
|
|
2457
|
+
statusCode: 409,
|
|
2458
|
+
message: 'Only submitted weeks can be reviewed',
|
|
2459
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalStale }
|
|
2460
|
+
})
|
|
2461
|
+
}
|
|
2462
|
+
if (action === 'REOPEN' && submission.status !== 'APPROVED') {
|
|
2463
|
+
throw createError({
|
|
2464
|
+
statusCode: 409,
|
|
2465
|
+
message: 'Only approved weeks can be reopened',
|
|
2466
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalStale }
|
|
2467
|
+
})
|
|
2468
|
+
}
|
|
2469
|
+
if (action === 'REOPEN') {
|
|
2470
|
+
const entries = await tx
|
|
2471
|
+
.select({ id: timeEntry.id })
|
|
2472
|
+
.from(timeEntry)
|
|
2473
|
+
.where(eq(timeEntry.submissionId, submission.id))
|
|
2474
|
+
await assertTimeEntriesReopenable(
|
|
2475
|
+
organizationId,
|
|
2476
|
+
entries.map((entry) => entry.id)
|
|
2477
|
+
)
|
|
2478
|
+
}
|
|
2479
|
+
const now = new Date()
|
|
2480
|
+
const nextStatus = action === 'APPROVE' ? 'APPROVED' : action === 'REJECT' ? 'REJECTED' : 'DRAFT'
|
|
2481
|
+
const expectedStatus = action === 'REOPEN' ? 'APPROVED' : 'SUBMITTED'
|
|
2482
|
+
const [updated] = await tx
|
|
2483
|
+
.update(timesheetSubmission)
|
|
2484
|
+
.set({
|
|
2485
|
+
status: nextStatus,
|
|
2486
|
+
reviewedAt: action === 'REOPEN' ? null : now,
|
|
2487
|
+
reviewedById: action === 'REOPEN' ? null : actorUserId,
|
|
2488
|
+
rejectionComment: action === 'REJECT' ? comment : null,
|
|
2489
|
+
version: sql`${timesheetSubmission.version} + 1`
|
|
2490
|
+
})
|
|
2491
|
+
.where(and(eq(timesheetSubmission.id, submission.id), eq(timesheetSubmission.status, expectedStatus)))
|
|
2492
|
+
.returning()
|
|
2493
|
+
if (!updated) {
|
|
2494
|
+
throw createError({
|
|
2495
|
+
statusCode: 409,
|
|
2496
|
+
message: 'This timesheet review has already changed',
|
|
2497
|
+
data: { code: TIMESHEET_ERROR_CODES.internalApprovalStale }
|
|
2498
|
+
})
|
|
2499
|
+
}
|
|
2500
|
+
await tx.insert(timesheetApprovalHistory).values({
|
|
2501
|
+
id: nanoid(),
|
|
2502
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
2503
|
+
submissionId: submission.id,
|
|
2504
|
+
action: action === 'APPROVE' ? 'APPROVED' : action === 'REJECT' ? 'REJECTED' : 'REOPENED',
|
|
2505
|
+
actorUserId,
|
|
2506
|
+
comment
|
|
2507
|
+
})
|
|
2508
|
+
if (action === 'APPROVE') {
|
|
2509
|
+
const representedClients = await tx
|
|
2510
|
+
.selectDistinct({ clientOrganizationId: timeEntry.clientOrganizationId })
|
|
2511
|
+
.from(timeEntry)
|
|
2512
|
+
.innerJoin(
|
|
2513
|
+
workspaceClient,
|
|
2514
|
+
and(
|
|
2515
|
+
eq(workspaceClient.workspaceOrganizationId, organizationId),
|
|
2516
|
+
eq(workspaceClient.clientOrganizationId, timeEntry.clientOrganizationId),
|
|
2517
|
+
eq(workspaceClient.accessMode, 'REVIEW')
|
|
2518
|
+
)
|
|
2519
|
+
)
|
|
2520
|
+
.where(eq(timeEntry.submissionId, submission.id))
|
|
2521
|
+
if (representedClients.length) {
|
|
2522
|
+
await tx
|
|
2523
|
+
.insert(timesheetClientReview)
|
|
2524
|
+
.values(
|
|
2525
|
+
representedClients.map((item) => ({
|
|
2526
|
+
id: nanoid(),
|
|
2527
|
+
weeklyTimesheetId: submission.weeklyTimesheetId,
|
|
2528
|
+
submissionId: submission.id,
|
|
2529
|
+
clientOrganizationId: item.clientOrganizationId,
|
|
2530
|
+
status: 'PENDING' as const
|
|
2531
|
+
}))
|
|
2532
|
+
)
|
|
2533
|
+
.onConflictDoNothing()
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
if (action === 'REOPEN') {
|
|
2537
|
+
await tx
|
|
2538
|
+
.update(timesheetClientReview)
|
|
2539
|
+
.set({
|
|
2540
|
+
status: 'PENDING',
|
|
2541
|
+
reviewerUserId: null,
|
|
2542
|
+
comment: null,
|
|
2543
|
+
reviewedAt: null,
|
|
2544
|
+
version: sql`${timesheetClientReview.version} + 1`,
|
|
2545
|
+
updatedAt: now
|
|
2546
|
+
})
|
|
2547
|
+
.where(eq(timesheetClientReview.submissionId, submission.id))
|
|
2548
|
+
}
|
|
2549
|
+
return updated
|
|
2550
|
+
})
|
|
2551
|
+
await notifyTimesheetEvent(
|
|
2552
|
+
result,
|
|
2553
|
+
action === 'APPROVE' ? 'approved' : action === 'REJECT' ? 'rejected' : 'reopened',
|
|
2554
|
+
undefined,
|
|
2555
|
+
comment ?? undefined
|
|
2556
|
+
)
|
|
2557
|
+
return result
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
export const getReport = async (organizationId: string, filters: ReportQuery): Promise<TimesheetReportDto> => {
|
|
2561
|
+
const conditions = [eq(timeEntry.organizationId, organizationId)]
|
|
2562
|
+
if (filters.from) {
|
|
2563
|
+
conditions.push(gte(timeEntry.entryDate, filters.from))
|
|
2564
|
+
}
|
|
2565
|
+
if (filters.to) {
|
|
2566
|
+
conditions.push(lte(timeEntry.entryDate, filters.to))
|
|
2567
|
+
}
|
|
2568
|
+
if (filters.projectId) {
|
|
2569
|
+
conditions.push(eq(timeEntry.projectId, filters.projectId))
|
|
2570
|
+
}
|
|
2571
|
+
if (filters.userId) {
|
|
2572
|
+
conditions.push(eq(timeEntry.userId, filters.userId))
|
|
2573
|
+
}
|
|
2574
|
+
if (filters.activityTypeId) {
|
|
2575
|
+
conditions.push(eq(timeEntry.activityTypeId, filters.activityTypeId))
|
|
2576
|
+
}
|
|
2577
|
+
if (filters.billable !== undefined) {
|
|
2578
|
+
conditions.push(eq(timeEntry.billableSnapshot, filters.billable))
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
const [entries, projects, activities, members, submissions, settings] = await Promise.all([
|
|
2582
|
+
db
|
|
2583
|
+
.select()
|
|
2584
|
+
.from(timeEntry)
|
|
2585
|
+
.where(and(...conditions))
|
|
2586
|
+
.orderBy(desc(timeEntry.entryDate)),
|
|
2587
|
+
listProjects(organizationId),
|
|
2588
|
+
listActivities(organizationId),
|
|
2589
|
+
listPortalOrganizationMembers(organizationId),
|
|
2590
|
+
db.select().from(timesheetSubmission).where(eq(timesheetSubmission.organizationId, organizationId)),
|
|
2591
|
+
ensureSettings(organizationId)
|
|
2592
|
+
])
|
|
2593
|
+
const projectMap = new Map(projects.map((item) => [item.id, item]))
|
|
2594
|
+
const activityMap = new Map(activities.map((item) => [item.id, item]))
|
|
2595
|
+
const memberMap = new Map(members.map((item) => [item.id, item]))
|
|
2596
|
+
const submissionMap = new Map(submissions.map((item) => [item.id, item]))
|
|
2597
|
+
const rows: ReportRowDto[] = entries.flatMap((entry) => {
|
|
2598
|
+
const selectedProject = projectMap.get(entry.projectId)
|
|
2599
|
+
const selectedSubmission = entry.submissionId ? submissionMap.get(entry.submissionId) : undefined
|
|
2600
|
+
if (!selectedProject) {
|
|
2601
|
+
return []
|
|
2602
|
+
}
|
|
2603
|
+
if (filters.clientOrganizationId && selectedProject.clientOrganizationId !== filters.clientOrganizationId) {
|
|
2604
|
+
return []
|
|
2605
|
+
}
|
|
2606
|
+
const entryStatus = selectedSubmission?.status ?? 'DRAFT'
|
|
2607
|
+
if (filters.status && entryStatus !== filters.status) {
|
|
2608
|
+
return []
|
|
2609
|
+
}
|
|
2610
|
+
return [
|
|
2611
|
+
{
|
|
2612
|
+
entryId: entry.id,
|
|
2613
|
+
date: entry.entryDate,
|
|
2614
|
+
client: selectedProject.clientName,
|
|
2615
|
+
project: selectedProject.name,
|
|
2616
|
+
person: memberMap.get(entry.userId)?.name ?? 'Unknown member',
|
|
2617
|
+
activity: activityMap.get(entry.activityTypeId)?.name ?? 'Unknown activity',
|
|
2618
|
+
minutes: entry.durationMinutes,
|
|
2619
|
+
billable: entry.billableSnapshot,
|
|
2620
|
+
hourlyRateMinor: entry.hourlyRateMinorSnapshot,
|
|
2621
|
+
amountMinor: Math.round((entry.durationMinutes * entry.hourlyRateMinorSnapshot) / 60),
|
|
2622
|
+
currency: entry.currencySnapshot,
|
|
2623
|
+
status: entryStatus,
|
|
2624
|
+
note: entry.note
|
|
2625
|
+
}
|
|
2626
|
+
]
|
|
2627
|
+
})
|
|
2628
|
+
return {
|
|
2629
|
+
rows,
|
|
2630
|
+
totals: {
|
|
2631
|
+
minutes: rows.reduce((sum, row) => sum + row.minutes, 0),
|
|
2632
|
+
billableMinutes: rows.filter((row) => row.billable).reduce((sum, row) => sum + row.minutes, 0),
|
|
2633
|
+
nonBillableMinutes: rows.filter((row) => !row.billable).reduce((sum, row) => sum + row.minutes, 0),
|
|
2634
|
+
billableAmountMinor: rows.reduce((sum, row) => sum + row.amountMinor, 0),
|
|
2635
|
+
currency: settings.currency
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
export const updateSettings = async (
|
|
2641
|
+
organizationId: string,
|
|
2642
|
+
input: Partial<{ currency: string; timezone: string; timerRoundingMinutes: number }>
|
|
2643
|
+
) => {
|
|
2644
|
+
await ensureSettings(organizationId)
|
|
2645
|
+
const [updated] = await db
|
|
2646
|
+
.update(workspaceSettings)
|
|
2647
|
+
.set(input)
|
|
2648
|
+
.where(eq(workspaceSettings.organizationId, organizationId))
|
|
2649
|
+
.returning()
|
|
2650
|
+
return updated
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
export const getClientTimesheets = async (
|
|
2654
|
+
workspaceClientId: string,
|
|
2655
|
+
clientOrganizationId: string,
|
|
2656
|
+
userId: string,
|
|
2657
|
+
canManageReviewers: boolean
|
|
2658
|
+
): Promise<ClientTimesheetsDto> => {
|
|
2659
|
+
const workspaces = await listClientWorkspaces(clientOrganizationId, userId, canManageReviewers)
|
|
2660
|
+
const workspace = workspaces.find((item) => item.id === workspaceClientId)
|
|
2661
|
+
if (!workspace) {
|
|
2662
|
+
throw createError({ statusCode: 404, message: 'Client workspace not found' })
|
|
2663
|
+
}
|
|
2664
|
+
if (workspace.accessMode !== 'VIEW') {
|
|
2665
|
+
throw createError({ statusCode: 403, message: 'Use the timesheet approvals endpoint for review access' })
|
|
2666
|
+
}
|
|
2667
|
+
const entries = await db
|
|
2668
|
+
.select({
|
|
2669
|
+
entry: timeEntry,
|
|
2670
|
+
week: weeklyTimesheet,
|
|
2671
|
+
submission: timesheetSubmission,
|
|
2672
|
+
projectName: project.name,
|
|
2673
|
+
activityName: activityType.name,
|
|
2674
|
+
personName: user.name
|
|
2675
|
+
})
|
|
2676
|
+
.from(timeEntry)
|
|
2677
|
+
.innerJoin(timesheetSubmission, eq(timesheetSubmission.id, timeEntry.submissionId))
|
|
2678
|
+
.innerJoin(weeklyTimesheet, eq(weeklyTimesheet.id, timeEntry.weeklyTimesheetId))
|
|
2679
|
+
.innerJoin(project, eq(project.id, timeEntry.projectId))
|
|
2680
|
+
.innerJoin(activityType, eq(activityType.id, timeEntry.activityTypeId))
|
|
2681
|
+
.innerJoin(user, eq(user.id, timeEntry.userId))
|
|
2682
|
+
.where(
|
|
2683
|
+
and(
|
|
2684
|
+
eq(timeEntry.organizationId, workspace.workspaceOrganizationId),
|
|
2685
|
+
eq(timeEntry.clientOrganizationId, clientOrganizationId),
|
|
2686
|
+
eq(timesheetSubmission.status, 'APPROVED')
|
|
2687
|
+
)
|
|
2688
|
+
)
|
|
2689
|
+
.orderBy(desc(weeklyTimesheet.weekStartsOn), asc(timeEntry.entryDate), asc(timeEntry.createdAt))
|
|
2690
|
+
const submissionIds = [...new Set(entries.map((item) => item.submission.id))]
|
|
2691
|
+
const reviews = submissionIds.length
|
|
2692
|
+
? await db
|
|
2693
|
+
.select()
|
|
2694
|
+
.from(timesheetClientReview)
|
|
2695
|
+
.where(
|
|
2696
|
+
and(
|
|
2697
|
+
inArray(timesheetClientReview.submissionId, submissionIds),
|
|
2698
|
+
eq(timesheetClientReview.clientOrganizationId, clientOrganizationId)
|
|
2699
|
+
)
|
|
2700
|
+
)
|
|
2701
|
+
: []
|
|
2702
|
+
const [internalHistory, clientHistory] = submissionIds.length
|
|
2703
|
+
? await Promise.all([
|
|
2704
|
+
db
|
|
2705
|
+
.select({ history: timesheetApprovalHistory, actorName: user.name })
|
|
2706
|
+
.from(timesheetApprovalHistory)
|
|
2707
|
+
.innerJoin(user, eq(user.id, timesheetApprovalHistory.actorUserId))
|
|
2708
|
+
.where(
|
|
2709
|
+
and(
|
|
2710
|
+
inArray(timesheetApprovalHistory.submissionId, submissionIds),
|
|
2711
|
+
inArray(timesheetApprovalHistory.action, ['SUBMITTED', 'APPROVED', 'REOPENED'])
|
|
2712
|
+
)
|
|
2713
|
+
),
|
|
2714
|
+
db
|
|
2715
|
+
.select({ history: timesheetClientReviewHistory, actorName: user.name })
|
|
2716
|
+
.from(timesheetClientReviewHistory)
|
|
2717
|
+
.innerJoin(user, eq(user.id, timesheetClientReviewHistory.actorUserId))
|
|
2718
|
+
.where(
|
|
2719
|
+
and(
|
|
2720
|
+
inArray(timesheetClientReviewHistory.submissionId, submissionIds),
|
|
2721
|
+
eq(timesheetClientReviewHistory.clientOrganizationId, clientOrganizationId)
|
|
2722
|
+
)
|
|
2723
|
+
)
|
|
2724
|
+
])
|
|
2725
|
+
: [[], []]
|
|
2726
|
+
const reviewBySubmission = new Map(reviews.map((item) => [item.submissionId, item]))
|
|
2727
|
+
const slices = submissionIds.map((submissionId) => {
|
|
2728
|
+
const rows = entries.filter((item) => item.submission.id === submissionId)
|
|
2729
|
+
const review = reviewBySubmission.get(submissionId)
|
|
2730
|
+
return {
|
|
2731
|
+
weeklyTimesheetId: submissionId,
|
|
2732
|
+
submissionId,
|
|
2733
|
+
weekStartsOn: rows[0]!.week.weekStartsOn,
|
|
2734
|
+
periodStartsOn: rows[0]!.submission.periodStartsOn,
|
|
2735
|
+
periodEndsOn: rows[0]!.submission.periodEndsOn,
|
|
2736
|
+
person: rows[0]!.personName,
|
|
2737
|
+
status: review?.status ?? ('PENDING' as const),
|
|
2738
|
+
version: review?.version ?? 0,
|
|
2739
|
+
comment: review?.comment ?? null,
|
|
2740
|
+
reviewedAt: review?.reviewedAt?.toISOString() ?? null,
|
|
2741
|
+
entries: rows.map(({ entry, projectName, activityName, personName }) => ({
|
|
2742
|
+
id: entry.id,
|
|
2743
|
+
date: entry.entryDate,
|
|
2744
|
+
project: projectName,
|
|
2745
|
+
person: personName,
|
|
2746
|
+
activity: activityName,
|
|
2747
|
+
minutes: entry.durationMinutes,
|
|
2748
|
+
note: entry.note
|
|
2749
|
+
})),
|
|
2750
|
+
history: [
|
|
2751
|
+
...internalHistory
|
|
2752
|
+
.filter((item) => item.history.submissionId === submissionId)
|
|
2753
|
+
.map((item) => ({
|
|
2754
|
+
id: item.history.id,
|
|
2755
|
+
action:
|
|
2756
|
+
item.history.action === 'SUBMITTED'
|
|
2757
|
+
? ('SUBMITTED' as const)
|
|
2758
|
+
: item.history.action === 'REOPENED'
|
|
2759
|
+
? ('REOPENED' as const)
|
|
2760
|
+
: ('APPROVED_INTERNAL' as const),
|
|
2761
|
+
actorName: item.actorName,
|
|
2762
|
+
comment: null,
|
|
2763
|
+
createdAt: item.history.createdAt.toISOString()
|
|
2764
|
+
})),
|
|
2765
|
+
...clientHistory
|
|
2766
|
+
.filter((item) => item.history.submissionId === submissionId)
|
|
2767
|
+
.map((item) => ({
|
|
2768
|
+
id: item.history.id,
|
|
2769
|
+
action:
|
|
2770
|
+
item.history.action === 'SUBMITTED'
|
|
2771
|
+
? ('SUBMITTED' as const)
|
|
2772
|
+
: item.history.action === 'APPROVED'
|
|
2773
|
+
? ('APPROVED_CLIENT' as const)
|
|
2774
|
+
: ('DISPUTED_CLIENT' as const),
|
|
2775
|
+
actorName: item.actorName,
|
|
2776
|
+
comment: item.history.comment,
|
|
2777
|
+
createdAt: item.history.createdAt.toISOString()
|
|
2778
|
+
}))
|
|
2779
|
+
].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
2780
|
+
}
|
|
2781
|
+
})
|
|
2782
|
+
return { workspace, slices }
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
export const listClientSupplierTimesheets = async (
|
|
2786
|
+
clientOrganizationId: string,
|
|
2787
|
+
userId: string,
|
|
2788
|
+
isAdmin: boolean
|
|
2789
|
+
): Promise<ClientSupplierTimesheetItemDto[]> => {
|
|
2790
|
+
const workspaces = (await listClientWorkspaces(clientOrganizationId, userId, isAdmin)).filter(
|
|
2791
|
+
(item) => item.accessMode === 'VIEW'
|
|
2792
|
+
)
|
|
2793
|
+
const collections = await Promise.all(
|
|
2794
|
+
workspaces.map(async (workspace) => {
|
|
2795
|
+
const result = await getClientTimesheets(workspace.id, clientOrganizationId, userId, isAdmin)
|
|
2796
|
+
return result.slices.map((slice) => ({
|
|
2797
|
+
...slice,
|
|
2798
|
+
id: slice.submissionId,
|
|
2799
|
+
workspaceClientId: workspace.id,
|
|
2800
|
+
supplierName: workspace.workspaceName,
|
|
2801
|
+
totalMinutes: slice.entries.reduce((total, entry) => total + entry.minutes, 0)
|
|
2802
|
+
}))
|
|
2803
|
+
})
|
|
2804
|
+
)
|
|
2805
|
+
return collections.flat()
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
export const listClientSupplierOptions = async (clientOrganizationId: string, userId: string, isAdmin: boolean) =>
|
|
2809
|
+
(await listClientWorkspaces(clientOrganizationId, userId, isAdmin))
|
|
2810
|
+
.filter((item) => item.accessMode === 'VIEW')
|
|
2811
|
+
.map((item) => ({ id: item.id, name: item.workspaceName }))
|
|
2812
|
+
|
|
2813
|
+
export const reviewClientTimesheet = async (
|
|
2814
|
+
workspaceClientId: string,
|
|
2815
|
+
clientOrganizationId: string,
|
|
2816
|
+
actorUserId: string,
|
|
2817
|
+
isAdmin: boolean,
|
|
2818
|
+
submissionId: string,
|
|
2819
|
+
action: 'APPROVE' | 'DISPUTE',
|
|
2820
|
+
expectedVersion: number,
|
|
2821
|
+
comment?: string | null
|
|
2822
|
+
) => {
|
|
2823
|
+
const result = await db.transaction(async (tx) => {
|
|
2824
|
+
const [link] = await tx
|
|
2825
|
+
.select()
|
|
2826
|
+
.from(workspaceClient)
|
|
2827
|
+
.where(
|
|
2828
|
+
and(
|
|
2829
|
+
eq(workspaceClient.id, workspaceClientId),
|
|
2830
|
+
eq(workspaceClient.clientOrganizationId, clientOrganizationId),
|
|
2831
|
+
eq(workspaceClient.accessMode, 'REVIEW')
|
|
2832
|
+
)
|
|
2833
|
+
)
|
|
2834
|
+
.limit(1)
|
|
2835
|
+
if (!link) {
|
|
2836
|
+
throw createError({ statusCode: 404, message: 'Review-enabled client workspace not found' })
|
|
2837
|
+
}
|
|
2838
|
+
const [reviewer] = await tx
|
|
2839
|
+
.select()
|
|
2840
|
+
.from(workspaceClientReviewer)
|
|
2841
|
+
.where(
|
|
2842
|
+
and(
|
|
2843
|
+
eq(workspaceClientReviewer.workspaceClientId, workspaceClientId),
|
|
2844
|
+
eq(workspaceClientReviewer.userId, actorUserId)
|
|
2845
|
+
)
|
|
2846
|
+
)
|
|
2847
|
+
.limit(1)
|
|
2848
|
+
if (!isAdmin && !reviewer) {
|
|
2849
|
+
throw createError({ statusCode: 403, message: 'Client reviewer access required' })
|
|
2850
|
+
}
|
|
2851
|
+
const [eligible] = await tx
|
|
2852
|
+
.select({ id: timeEntry.id })
|
|
2853
|
+
.from(timeEntry)
|
|
2854
|
+
.innerJoin(timesheetSubmission, eq(timesheetSubmission.id, timeEntry.submissionId))
|
|
2855
|
+
.where(
|
|
2856
|
+
and(
|
|
2857
|
+
eq(timeEntry.submissionId, submissionId),
|
|
2858
|
+
eq(timeEntry.organizationId, link.workspaceOrganizationId),
|
|
2859
|
+
eq(timeEntry.clientOrganizationId, clientOrganizationId),
|
|
2860
|
+
eq(timesheetSubmission.status, 'APPROVED')
|
|
2861
|
+
)
|
|
2862
|
+
)
|
|
2863
|
+
.limit(1)
|
|
2864
|
+
if (!eligible) {
|
|
2865
|
+
throw createError({ statusCode: 404, message: 'Client timesheet slice not found' })
|
|
2866
|
+
}
|
|
2867
|
+
const now = new Date()
|
|
2868
|
+
const [updated] = await tx
|
|
2869
|
+
.update(timesheetClientReview)
|
|
2870
|
+
.set({
|
|
2871
|
+
status: action === 'APPROVE' ? 'APPROVED' : 'DISPUTED',
|
|
2872
|
+
reviewerUserId: actorUserId,
|
|
2873
|
+
comment: comment ?? null,
|
|
2874
|
+
reviewedAt: now,
|
|
2875
|
+
version: expectedVersion + 1,
|
|
2876
|
+
updatedAt: now
|
|
2877
|
+
})
|
|
2878
|
+
.where(
|
|
2879
|
+
and(
|
|
2880
|
+
eq(timesheetClientReview.submissionId, submissionId),
|
|
2881
|
+
eq(timesheetClientReview.clientOrganizationId, clientOrganizationId),
|
|
2882
|
+
eq(timesheetClientReview.status, 'PENDING'),
|
|
2883
|
+
eq(timesheetClientReview.version, expectedVersion)
|
|
2884
|
+
)
|
|
2885
|
+
)
|
|
2886
|
+
.returning()
|
|
2887
|
+
if (!updated) {
|
|
2888
|
+
throw createError({ statusCode: 409, message: 'Client review changed; refresh and try again' })
|
|
2889
|
+
}
|
|
2890
|
+
await tx.insert(timesheetClientReviewHistory).values({
|
|
2891
|
+
id: nanoid(),
|
|
2892
|
+
weeklyTimesheetId: updated.weeklyTimesheetId,
|
|
2893
|
+
submissionId,
|
|
2894
|
+
clientOrganizationId,
|
|
2895
|
+
action: action === 'APPROVE' ? 'APPROVED' : 'DISPUTED',
|
|
2896
|
+
actorUserId,
|
|
2897
|
+
comment: comment ?? null,
|
|
2898
|
+
createdAt: now
|
|
2899
|
+
})
|
|
2900
|
+
return updated
|
|
2901
|
+
})
|
|
2902
|
+
await notifyClientTimesheetEvent(result)
|
|
2903
|
+
return result
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
export const reportToCsv = (report: TimesheetReportDto) => {
|
|
2907
|
+
const quote = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`
|
|
2908
|
+
return [
|
|
2909
|
+
[
|
|
2910
|
+
'Date',
|
|
2911
|
+
'Client',
|
|
2912
|
+
'Project',
|
|
2913
|
+
'Person',
|
|
2914
|
+
'Activity',
|
|
2915
|
+
'Hours',
|
|
2916
|
+
'Billable',
|
|
2917
|
+
'Rate',
|
|
2918
|
+
'Amount',
|
|
2919
|
+
'Currency',
|
|
2920
|
+
'Status',
|
|
2921
|
+
'Note'
|
|
2922
|
+
],
|
|
2923
|
+
...report.rows.map((row) => [
|
|
2924
|
+
row.date,
|
|
2925
|
+
row.client,
|
|
2926
|
+
row.project,
|
|
2927
|
+
row.person,
|
|
2928
|
+
row.activity,
|
|
2929
|
+
(row.minutes / 60).toFixed(2),
|
|
2930
|
+
row.billable ? 'Yes' : 'No',
|
|
2931
|
+
(row.hourlyRateMinor / 100).toFixed(2),
|
|
2932
|
+
(row.amountMinor / 100).toFixed(2),
|
|
2933
|
+
row.currency,
|
|
2934
|
+
row.status,
|
|
2935
|
+
row.note ?? ''
|
|
2936
|
+
])
|
|
2937
|
+
]
|
|
2938
|
+
.map((row) => row.map(quote).join(','))
|
|
2939
|
+
.join('\n')
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
export const replyClientTimesheet = async (
|
|
2943
|
+
organizationId: string,
|
|
2944
|
+
userId: string,
|
|
2945
|
+
submissionId: string,
|
|
2946
|
+
clientOrganizationId: string,
|
|
2947
|
+
expectedVersion: number,
|
|
2948
|
+
reply?: string
|
|
2949
|
+
) => {
|
|
2950
|
+
const members = await listPortalOrganizationMembers(organizationId)
|
|
2951
|
+
const isAdmin = members.some((member) => member.id === userId && ['owner', 'admin'].includes(member.organizationRole))
|
|
2952
|
+
const submission = await db.transaction(async (tx) => {
|
|
2953
|
+
const [owned] = await tx
|
|
2954
|
+
.select()
|
|
2955
|
+
.from(timesheetSubmission)
|
|
2956
|
+
.where(
|
|
2957
|
+
and(
|
|
2958
|
+
eq(timesheetSubmission.id, submissionId),
|
|
2959
|
+
eq(timesheetSubmission.organizationId, organizationId),
|
|
2960
|
+
isAdmin ? undefined : eq(timesheetSubmission.userId, userId),
|
|
2961
|
+
eq(timesheetSubmission.status, 'APPROVED')
|
|
2962
|
+
)
|
|
2963
|
+
)
|
|
2964
|
+
.limit(1)
|
|
2965
|
+
if (!owned) {
|
|
2966
|
+
throw createError({ statusCode: 404, message: 'Approved submission not found' })
|
|
2967
|
+
}
|
|
2968
|
+
const [review] = await tx
|
|
2969
|
+
.update(timesheetClientReview)
|
|
2970
|
+
.set({
|
|
2971
|
+
status: 'PENDING',
|
|
2972
|
+
comment: null,
|
|
2973
|
+
reviewerUserId: null,
|
|
2974
|
+
reviewedAt: null,
|
|
2975
|
+
version: expectedVersion + 1,
|
|
2976
|
+
updatedAt: new Date()
|
|
2977
|
+
})
|
|
2978
|
+
.where(
|
|
2979
|
+
and(
|
|
2980
|
+
eq(timesheetClientReview.submissionId, submissionId),
|
|
2981
|
+
eq(timesheetClientReview.clientOrganizationId, clientOrganizationId),
|
|
2982
|
+
eq(timesheetClientReview.status, 'DISPUTED'),
|
|
2983
|
+
eq(timesheetClientReview.version, expectedVersion)
|
|
2984
|
+
)
|
|
2985
|
+
)
|
|
2986
|
+
.returning()
|
|
2987
|
+
if (!review) {
|
|
2988
|
+
throw createError({ statusCode: 409, message: 'Client review changed; refresh and try again' })
|
|
2989
|
+
}
|
|
2990
|
+
await tx.insert(timesheetClientReviewHistory).values({
|
|
2991
|
+
id: nanoid(),
|
|
2992
|
+
weeklyTimesheetId: owned.weeklyTimesheetId,
|
|
2993
|
+
submissionId,
|
|
2994
|
+
clientOrganizationId,
|
|
2995
|
+
action: 'SUBMITTED',
|
|
2996
|
+
actorUserId: userId,
|
|
2997
|
+
comment: reply?.trim() || null
|
|
2998
|
+
})
|
|
2999
|
+
return owned
|
|
3000
|
+
})
|
|
3001
|
+
await notifyTimesheetEvent(submission, 'approved', undefined, reply, clientOrganizationId)
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
export const requestClientApproval = async (
|
|
3005
|
+
organizationId: string,
|
|
3006
|
+
submissionId: string,
|
|
3007
|
+
clientOrganizationId: string,
|
|
3008
|
+
actorUserId: string,
|
|
3009
|
+
message?: string
|
|
3010
|
+
) => {
|
|
3011
|
+
const submission = await db.transaction(async (tx) => {
|
|
3012
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${'timesheet-submit:' + submissionId}))`)
|
|
3013
|
+
const [current] = await tx
|
|
3014
|
+
.select()
|
|
3015
|
+
.from(timesheetSubmission)
|
|
3016
|
+
.where(
|
|
3017
|
+
and(
|
|
3018
|
+
eq(timesheetSubmission.id, submissionId),
|
|
3019
|
+
eq(timesheetSubmission.organizationId, organizationId),
|
|
3020
|
+
eq(timesheetSubmission.status, 'APPROVED')
|
|
3021
|
+
)
|
|
3022
|
+
)
|
|
3023
|
+
.limit(1)
|
|
3024
|
+
if (!current) {
|
|
3025
|
+
throw createError({
|
|
3026
|
+
statusCode: 404,
|
|
3027
|
+
message: 'Approved submission not found',
|
|
3028
|
+
data: { code: 'CLIENT_APPROVAL_NOT_READY' }
|
|
3029
|
+
})
|
|
3030
|
+
}
|
|
3031
|
+
const [link] = await tx
|
|
3032
|
+
.select()
|
|
3033
|
+
.from(workspaceClient)
|
|
3034
|
+
.where(
|
|
3035
|
+
and(
|
|
3036
|
+
eq(workspaceClient.workspaceOrganizationId, organizationId),
|
|
3037
|
+
eq(workspaceClient.clientOrganizationId, clientOrganizationId),
|
|
3038
|
+
eq(workspaceClient.accessMode, 'REVIEW')
|
|
3039
|
+
)
|
|
3040
|
+
)
|
|
3041
|
+
.limit(1)
|
|
3042
|
+
if (!link) {
|
|
3043
|
+
throw createError({
|
|
3044
|
+
statusCode: 409,
|
|
3045
|
+
message: 'Enable timesheet review access for this client first',
|
|
3046
|
+
data: { code: 'CLIENT_APPROVAL_ACCESS_REQUIRED' }
|
|
3047
|
+
})
|
|
3048
|
+
}
|
|
3049
|
+
const entries = await tx
|
|
3050
|
+
.select()
|
|
3051
|
+
.from(timeEntry)
|
|
3052
|
+
.where(and(eq(timeEntry.submissionId, submissionId), eq(timeEntry.clientOrganizationId, clientOrganizationId)))
|
|
3053
|
+
if (!entries.length) {
|
|
3054
|
+
throw createError({
|
|
3055
|
+
statusCode: 422,
|
|
3056
|
+
message: 'No hours for this client',
|
|
3057
|
+
data: { code: 'CLIENT_APPROVAL_EMPTY' }
|
|
3058
|
+
})
|
|
3059
|
+
}
|
|
3060
|
+
await assertTimeEntriesReopenable(
|
|
3061
|
+
organizationId,
|
|
3062
|
+
entries.map((entry) => entry.id)
|
|
3063
|
+
)
|
|
3064
|
+
const inserted = await tx
|
|
3065
|
+
.insert(timesheetClientReview)
|
|
3066
|
+
.values({
|
|
3067
|
+
id: nanoid(),
|
|
3068
|
+
weeklyTimesheetId: current.weeklyTimesheetId,
|
|
3069
|
+
submissionId,
|
|
3070
|
+
clientOrganizationId,
|
|
3071
|
+
status: 'PENDING'
|
|
3072
|
+
})
|
|
3073
|
+
.onConflictDoNothing()
|
|
3074
|
+
.returning()
|
|
3075
|
+
if (!inserted.length) {
|
|
3076
|
+
throw createError({
|
|
3077
|
+
statusCode: 409,
|
|
3078
|
+
message: 'Client approval already exists',
|
|
3079
|
+
data: { code: 'CLIENT_APPROVAL_EXISTS' }
|
|
3080
|
+
})
|
|
3081
|
+
}
|
|
3082
|
+
if (message?.trim()) {
|
|
3083
|
+
await tx.insert(timesheetClientReviewHistory).values({
|
|
3084
|
+
weeklyTimesheetId: current.weeklyTimesheetId,
|
|
3085
|
+
id: nanoid(),
|
|
3086
|
+
submissionId,
|
|
3087
|
+
clientOrganizationId,
|
|
3088
|
+
action: 'SUBMITTED',
|
|
3089
|
+
actorUserId,
|
|
3090
|
+
comment: message.trim()
|
|
3091
|
+
})
|
|
3092
|
+
}
|
|
3093
|
+
return current
|
|
3094
|
+
})
|
|
3095
|
+
await notifyTimesheetEvent(submission, 'approved', undefined, message, clientOrganizationId)
|
|
3096
|
+
}
|