@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.
Files changed (141) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +5 -0
  4. package/app/assets/timesheets.css +368 -0
  5. package/app/components/TimesheetsActivityForm.vue +61 -0
  6. package/app/components/TimesheetsAdminActivities.vue +323 -0
  7. package/app/components/TimesheetsAdminApprovals.vue +449 -0
  8. package/app/components/TimesheetsAdminEmptyState.vue +28 -0
  9. package/app/components/TimesheetsAdminListToolbar.vue +19 -0
  10. package/app/components/TimesheetsAdminPaginatedList.vue +95 -0
  11. package/app/components/TimesheetsAdminProjects.vue +234 -0
  12. package/app/components/TimesheetsAdminRates.vue +83 -0
  13. package/app/components/TimesheetsAdminReports.vue +123 -0
  14. package/app/components/TimesheetsAdminSectionPage.vue +135 -0
  15. package/app/components/TimesheetsAdminSettings.vue +15 -0
  16. package/app/components/TimesheetsAdministrationOrganizationPanel.vue +26 -0
  17. package/app/components/TimesheetsClientSettingsPanel.vue +52 -0
  18. package/app/components/TimesheetsClientTimesheetBrowser.vue +439 -0
  19. package/app/components/TimesheetsDashboardCard.vue +260 -0
  20. package/app/components/TimesheetsDashboardClientApprovals.vue +1 -0
  21. package/app/components/TimesheetsDashboardInternalApprovals.vue +1 -0
  22. package/app/components/TimesheetsDashboardMyTimesheets.vue +1 -0
  23. package/app/components/TimesheetsDashboardMyWeek.vue +1 -0
  24. package/app/components/TimesheetsDashboardSupplierTimesheets.vue +47 -0
  25. package/app/components/TimesheetsOrganizationFeaturesCard.vue +66 -0
  26. package/app/components/TimesheetsPageShell.vue +48 -0
  27. package/app/components/TimesheetsProjectDetail.vue +338 -0
  28. package/app/components/TimesheetsProjectForm.vue +215 -0
  29. package/app/components/TimesheetsSetupChecklist.vue +48 -0
  30. package/app/components/TimesheetsSubmissionMessages.vue +30 -0
  31. package/app/components/TimesheetsSubmissionTimeline.vue +89 -0
  32. package/app/components/TimesheetsWorkspaceSettingsForm.vue +80 -0
  33. package/app/composables/useTimesheetMenu.ts +69 -0
  34. package/app/composables/useTimesheetMutationError.ts +35 -0
  35. package/app/composables/useTimesheets.ts +248 -0
  36. package/app/composables/useTimesheetsAdminList.ts +165 -0
  37. package/app/composables/useTimesheetsDashboard.ts +8 -0
  38. package/app/pages/admin/timesheets/activities.vue +3 -0
  39. package/app/pages/admin/timesheets/approvals.vue +3 -0
  40. package/app/pages/admin/timesheets/index.vue +7 -0
  41. package/app/pages/admin/timesheets/internal-approvals.vue +131 -0
  42. package/app/pages/admin/timesheets/projects/[id].vue +57 -0
  43. package/app/pages/admin/timesheets/projects.vue +8 -0
  44. package/app/pages/admin/timesheets/rates.vue +3 -0
  45. package/app/pages/admin/timesheets/reports.vue +3 -0
  46. package/app/pages/admin/timesheets/settings.vue +3 -0
  47. package/app/pages/timesheets/approvals/reviewers.vue +96 -0
  48. package/app/pages/timesheets/approvals.vue +11 -0
  49. package/app/pages/timesheets/client-approvals.vue +108 -0
  50. package/app/pages/timesheets/client.vue +17 -0
  51. package/app/pages/timesheets/index.vue +1458 -0
  52. package/app/pages/timesheets/internal-approvals.vue +100 -0
  53. package/app/pages/timesheets/suppliers.vue +23 -0
  54. package/app/plugins/timesheets-feature.ts +122 -0
  55. package/i18n/locales/en.json +455 -0
  56. package/i18n/locales/nl.json +455 -0
  57. package/migrations/0000_baseline.sql +409 -0
  58. package/migrations/0001_remove_unused_legacy_invoice_schema.sql +31 -0
  59. package/migrations/0002_submission_batches.sql +47 -0
  60. package/migrations/0003_sync_provider_clients.sql +26 -0
  61. package/migrations/0004_client_reply.sql +1 -0
  62. package/migrations/0005_timer_rounding.sql +1 -0
  63. package/migrations/meta/0000_snapshot.json +3178 -0
  64. package/migrations/meta/_journal.json +13 -0
  65. package/nuxt.config.ts +20 -0
  66. package/package.json +61 -0
  67. package/portal.manifest.mjs +10 -0
  68. package/server/api/timesheets/admin/activities/[id]/deletion.get.ts +18 -0
  69. package/server/api/timesheets/admin/activities/[id].delete.ts +20 -0
  70. package/server/api/timesheets/admin/activities/[id].patch.ts +19 -0
  71. package/server/api/timesheets/admin/activities/index.get.ts +19 -0
  72. package/server/api/timesheets/admin/activities/index.post.ts +19 -0
  73. package/server/api/timesheets/admin/approvals/[id].post.ts +19 -0
  74. package/server/api/timesheets/admin/bootstrap.get.ts +73 -0
  75. package/server/api/timesheets/admin/client-settings/[clientId]/index.get.ts +25 -0
  76. package/server/api/timesheets/admin/client-settings/[clientId]/index.put.ts +28 -0
  77. package/server/api/timesheets/admin/clients/[id]/deletion.get.ts +18 -0
  78. package/server/api/timesheets/admin/clients/[id].delete.ts +20 -0
  79. package/server/api/timesheets/admin/clients/[id].patch.ts +14 -0
  80. package/server/api/timesheets/admin/clients/index.get.ts +18 -0
  81. package/server/api/timesheets/admin/clients/index.post.ts +25 -0
  82. package/server/api/timesheets/admin/internal-approvals/[userId].put.ts +11 -0
  83. package/server/api/timesheets/admin/internal-approvals/index.get.ts +8 -0
  84. package/server/api/timesheets/admin/internal-approvals/index.patch.ts +11 -0
  85. package/server/api/timesheets/admin/organization-capabilities/[organizationId].get.ts +19 -0
  86. package/server/api/timesheets/admin/organization-capabilities/[organizationId].patch.ts +23 -0
  87. package/server/api/timesheets/admin/projects/[id]/deletion.get.ts +18 -0
  88. package/server/api/timesheets/admin/projects/[id].delete.ts +20 -0
  89. package/server/api/timesheets/admin/projects/[id].get.ts +22 -0
  90. package/server/api/timesheets/admin/projects/[id].patch.ts +19 -0
  91. package/server/api/timesheets/admin/projects/index.get.ts +18 -0
  92. package/server/api/timesheets/admin/projects/index.post.ts +19 -0
  93. package/server/api/timesheets/admin/report.get.ts +26 -0
  94. package/server/api/timesheets/admin/settings.patch.ts +19 -0
  95. package/server/api/timesheets/admin/tariffs/index.put.ts +20 -0
  96. package/server/api/timesheets/admin/team/[userId].put.ts +24 -0
  97. package/server/api/timesheets/bootstrap.get.ts +24 -0
  98. package/server/api/timesheets/capabilities.get.ts +58 -0
  99. package/server/api/timesheets/client/[workspaceClientId]/index.get.ts +12 -0
  100. package/server/api/timesheets/client/[workspaceClientId]/reviewers.get.ts +10 -0
  101. package/server/api/timesheets/client/[workspaceClientId]/reviewers.put.ts +18 -0
  102. package/server/api/timesheets/client/[workspaceClientId]/reviews/[submissionId].post.ts +18 -0
  103. package/server/api/timesheets/client/approval-members.get.ts +11 -0
  104. package/server/api/timesheets/client/approval-suppliers.get.ts +8 -0
  105. package/server/api/timesheets/client/approvals.get.ts +14 -0
  106. package/server/api/timesheets/client/reviewer-suppliers.get.ts +10 -0
  107. package/server/api/timesheets/client/supplier-options.get.ts +14 -0
  108. package/server/api/timesheets/client/supplier-timesheets.get.ts +17 -0
  109. package/server/api/timesheets/client/workspaces.get.ts +11 -0
  110. package/server/api/timesheets/client-approvals.get.ts +18 -0
  111. package/server/api/timesheets/dashboard.get.ts +191 -0
  112. package/server/api/timesheets/entries/[id].delete.ts +18 -0
  113. package/server/api/timesheets/entries/[id].patch.ts +23 -0
  114. package/server/api/timesheets/entries/index.post.ts +22 -0
  115. package/server/api/timesheets/internal-approvals/[id].post.ts +9 -0
  116. package/server/api/timesheets/internal-approvals/index.get.ts +37 -0
  117. package/server/api/timesheets/internal-approvals/list.get.ts +12 -0
  118. package/server/api/timesheets/setup-status.get.ts +21 -0
  119. package/server/api/timesheets/submissions/[id]/client-reply.post.ts +20 -0
  120. package/server/api/timesheets/submissions/[id]/request-client-approval.post.ts +21 -0
  121. package/server/api/timesheets/submissions/[id]/resubmit.post.ts +10 -0
  122. package/server/api/timesheets/timer/index.delete.ts +18 -0
  123. package/server/api/timesheets/timer/index.post.ts +23 -0
  124. package/server/api/timesheets/weeks/[id]/submissions.post.ts +19 -0
  125. package/server/db/schema/timesheets.ts +383 -0
  126. package/server/plugins/client-sync.ts +30 -0
  127. package/server/plugins/openapi-contracts.ts +57 -0
  128. package/server/utils/timesheet-admin-listing.ts +281 -0
  129. package/server/utils/timesheet-email.ts +221 -0
  130. package/server/utils/timesheet-repository.ts +3096 -0
  131. package/server/utils/timesheet-validation.ts +231 -0
  132. package/shared/client-approval-dashboard.ts +17 -0
  133. package/shared/emails.ts +403 -0
  134. package/shared/feature.ts +184 -0
  135. package/shared/period-lock.ts +8 -0
  136. package/shared/submission-reply.ts +3 -0
  137. package/shared/timer-rounding.ts +2 -0
  138. package/shared/timesheet-dates.ts +23 -0
  139. package/shared/timesheet-errors.ts +13 -0
  140. package/shared/types/timesheet.ts +424 -0
  141. 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
+ }