@ossy/timesheets 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/timesheets",
3
3
  "description": "Timesheets — generate, review, save, and export monthly work hours",
4
- "version": "3.9.0",
4
+ "version": "3.10.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -17,7 +17,9 @@
17
17
  "src": "./src"
18
18
  },
19
19
  "dependencies": {
20
- "@ossy/resources": "^3.9.0",
20
+ "@ossy/email": "^3.0.9",
21
+ "@ossy/resources": "^3.10.0",
22
+ "@ossy/schema": "^3.8.0",
21
23
  "html-to-image": "^1.11.13",
22
24
  "nanoid": "^5.1.11",
23
25
  "pdf-lib": "^1.17.1"
@@ -41,5 +43,5 @@
41
43
  "/src",
42
44
  "README.md"
43
45
  ],
44
- "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
46
+ "gitHead": "198084eb98871876396ae625d043dd0f03aa0325"
45
47
  }
@@ -0,0 +1,70 @@
1
+ import { TimesheetRefs, TimesheetsSchema } from './schema-ids.js'
2
+ import {
3
+ findTimesheetForContractPeriod,
4
+ isContractActive,
5
+ periodForContract,
6
+ } from './billing-period.js'
7
+ import { optionalReference } from './timesheet-refs.js'
8
+
9
+ /** Company basic-info schema (consultancy) — used for holiday locale. */
10
+ export const COMPANY_BASIC_INFO_SCHEMA = '@ossy/consultancy/schema/company-basic-info'
11
+
12
+ /**
13
+ * Pick holiday locale for a workspace from company basic-info resources.
14
+ * @param {Array<{ belongsTo?: string, content?: { holidayLocale?: string } }>} companyResources
15
+ * @param {string} workspaceId
16
+ * @returns {string | undefined}
17
+ */
18
+ export function holidayLocaleForWorkspace (companyResources, workspaceId) {
19
+ const match = (companyResources || []).find((r) => r.belongsTo === workspaceId)
20
+ return match?.content?.holidayLocale
21
+ }
22
+
23
+ /**
24
+ * Decide which active contracts need a new auto-generated sheet for `asOf`.
25
+ *
26
+ * @param {object} input
27
+ * @param {Array<{ id: string, belongsTo?: string, content?: object }>} input.contracts
28
+ * @param {Array<{ id: string, belongsTo?: string, createdAt?: number, content?: object }>} input.timesheets
29
+ * @param {Date | number} [input.asOf]
30
+ * @returns {Array<{
31
+ * contractId: string,
32
+ * workspaceId: string,
33
+ * employeeId: string | null,
34
+ * periodStart: number,
35
+ * periodEnd: number,
36
+ * billingPeriod: string,
37
+ * }>}
38
+ */
39
+ export function contractsNeedingTimesheets ({ contracts, timesheets, asOf = Date.now() }) {
40
+ const needed = []
41
+
42
+ for (const contract of contracts || []) {
43
+ const workspaceId = contract.belongsTo
44
+ if (!workspaceId) continue
45
+ if (!isContractActive(contract.content, asOf)) continue
46
+
47
+ const { periodStart, periodEnd } = periodForContract(contract.content, asOf)
48
+ const existing = findTimesheetForContractPeriod(timesheets, contract.id, periodStart)
49
+ if (existing) continue
50
+
51
+ const employeeId = optionalReference(contract.content?.employeeId)?.resourceId ?? null
52
+
53
+ needed.push({
54
+ contractId: contract.id,
55
+ workspaceId,
56
+ employeeId,
57
+ periodStart,
58
+ periodEnd,
59
+ billingPeriod: contract.content?.billingPeriod === 'weekly' ? 'weekly' : 'monthly',
60
+ })
61
+ }
62
+
63
+ return needed
64
+ }
65
+
66
+ export const AUTO_GENERATE_QUERY = {
67
+ contracts: { type: TimesheetRefs.contract },
68
+ timesheets: { type: TimesheetsSchema.timesheet },
69
+ companies: { type: COMPANY_BASIC_INFO_SCHEMA },
70
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ contractsNeedingTimesheets,
4
+ holidayLocaleForWorkspace,
5
+ } from './auto-generate-timesheets.js'
6
+
7
+ describe('holidayLocaleForWorkspace', () => {
8
+ it('returns the company holidayLocale for the workspace', () => {
9
+ const companies = [
10
+ { belongsTo: 'ws-a', content: { holidayLocale: 'SE' } },
11
+ { belongsTo: 'ws-b', content: { holidayLocale: 'NO' } },
12
+ ]
13
+ expect(holidayLocaleForWorkspace(companies, 'ws-b')).toBe('NO')
14
+ expect(holidayLocaleForWorkspace(companies, 'ws-missing')).toBe(undefined)
15
+ })
16
+ })
17
+
18
+ describe('contractsNeedingTimesheets', () => {
19
+ const augustStart = new Date(2026, 7, 1).setHours(0, 0, 0, 0)
20
+ const asOf = new Date(2026, 7, 19)
21
+
22
+ it('skips inactive contracts and existing sheets', () => {
23
+ const contracts = [
24
+ {
25
+ id: 'c-active',
26
+ belongsTo: 'ws-1',
27
+ content: {
28
+ billingPeriod: 'monthly',
29
+ employeeId: 'emp-1',
30
+ startDate: new Date(2026, 0, 1).getTime(),
31
+ },
32
+ },
33
+ {
34
+ id: 'c-done',
35
+ belongsTo: 'ws-1',
36
+ content: {
37
+ billingPeriod: 'monthly',
38
+ startDate: new Date(2025, 0, 1).getTime(),
39
+ endDate: new Date(2026, 0, 1).getTime(),
40
+ },
41
+ },
42
+ {
43
+ id: 'c-has-sheet',
44
+ belongsTo: 'ws-1',
45
+ content: {
46
+ billingPeriod: 'monthly',
47
+ startDate: new Date(2026, 0, 1).getTime(),
48
+ },
49
+ },
50
+ ]
51
+
52
+ const timesheets = [
53
+ {
54
+ id: 'sheet-1',
55
+ createdAt: 1,
56
+ content: { periodStart: augustStart, contractId: 'c-has-sheet' },
57
+ },
58
+ ]
59
+
60
+ expect(contractsNeedingTimesheets({ contracts, timesheets, asOf })).toEqual([
61
+ {
62
+ contractId: 'c-active',
63
+ workspaceId: 'ws-1',
64
+ employeeId: 'emp-1',
65
+ periodStart: augustStart,
66
+ periodEnd: new Date(2026, 7, 31).setHours(0, 0, 0, 0),
67
+ billingPeriod: 'monthly',
68
+ },
69
+ ])
70
+ })
71
+
72
+ it('uses weekly period bounds when configured', () => {
73
+ const contracts = [{
74
+ id: 'c-week',
75
+ belongsTo: 'ws-2',
76
+ content: {
77
+ billingPeriod: 'weekly',
78
+ employeeId: { resourceId: 'emp-9' },
79
+ startDate: new Date(2026, 0, 1).getTime(),
80
+ },
81
+ }]
82
+
83
+ const [needed] = contractsNeedingTimesheets({ contracts, timesheets: [], asOf })
84
+ expect(needed.contractId).toBe('c-week')
85
+ expect(needed.employeeId).toBe('emp-9')
86
+ expect(needed.billingPeriod).toBe('weekly')
87
+ expect(new Date(needed.periodStart).getDay()).toBe(1)
88
+ expect(new Date(needed.periodEnd).getDay()).toBe(0)
89
+ })
90
+ })
@@ -0,0 +1,60 @@
1
+ import { ResourcesQueries } from '@ossy/resources/server'
2
+ import {
3
+ AUTO_GENERATE_QUERY,
4
+ contractsNeedingTimesheets,
5
+ holidayLocaleForWorkspace,
6
+ } from './auto-generate-timesheets.js'
7
+ import { createTimesheetDraft } from './create-timesheet-draft.js'
8
+
9
+ export const metadata = {
10
+ id: '@ossy/timesheets/tasks/auto-generate',
11
+ // Daily at 06:00 UTC — create sheets when a billing period is open
12
+ schedule: '0 6 * * *',
13
+ }
14
+
15
+ /**
16
+ * Auto-generate draft timesheets for active consultancy contracts based on
17
+ * each contract's billing period (weekly / monthly). Idempotent per
18
+ * (workspace, contractId, periodStart).
19
+ */
20
+ export async function run ({ log }) {
21
+ const now = Date.now()
22
+
23
+ const [contracts, timesheets, companies] = await Promise.all([
24
+ ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.contracts),
25
+ ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.timesheets),
26
+ ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.companies),
27
+ ])
28
+
29
+ const needed = contractsNeedingTimesheets({ contracts, timesheets, asOf: now })
30
+ log?.info(`[timesheets/tasks/auto-generate] ${needed.length} contract(s) need a sheet`)
31
+
32
+ let created = 0
33
+ let failed = 0
34
+
35
+ for (const item of needed) {
36
+ try {
37
+ const locale = holidayLocaleForWorkspace(companies, item.workspaceId)
38
+ await createTimesheetDraft({
39
+ workspaceId: item.workspaceId,
40
+ periodStart: item.periodStart,
41
+ periodEnd: item.periodEnd,
42
+ locale,
43
+ employeeId: item.employeeId,
44
+ contractId: item.contractId,
45
+ createdBy: metadata.id,
46
+ autoGenerated: true,
47
+ log,
48
+ })
49
+ created++
50
+ } catch (err) {
51
+ failed++
52
+ log?.error(
53
+ `[timesheets/tasks/auto-generate] failed for contract ${item.contractId}`,
54
+ err,
55
+ )
56
+ }
57
+ }
58
+
59
+ return { created, failed, candidates: needed.length }
60
+ }
@@ -0,0 +1,123 @@
1
+ import { optionalReference } from './timesheet-refs.js'
2
+
3
+ export const BILLING_PERIODS = Object.freeze(['weekly', 'monthly'])
4
+ export const DEFAULT_BILLING_PERIOD = 'monthly'
5
+
6
+ /**
7
+ * @param {unknown} value
8
+ * @returns {'weekly' | 'monthly'}
9
+ */
10
+ export function normalizeBillingPeriod (value) {
11
+ if (value === 'weekly' || value === 'monthly') return value
12
+ return DEFAULT_BILLING_PERIOD
13
+ }
14
+
15
+ /**
16
+ * @param {unknown} value — Unix ms, ISO string, or Date
17
+ * @returns {number | null}
18
+ */
19
+ export function toMs (value) {
20
+ if (value == null || value === '') return null
21
+ if (typeof value === 'number' && Number.isFinite(value)) return value
22
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.getTime()
23
+ if (typeof value === 'string') {
24
+ const parsed = Date.parse(value)
25
+ if (!Number.isNaN(parsed)) return parsed
26
+ }
27
+ return null
28
+ }
29
+
30
+ /**
31
+ * Calendar-month period bounds as local midnights, returned as Unix ms.
32
+ * @param {number} year
33
+ * @param {number} monthIndex 0–11
34
+ */
35
+ export function monthPeriodBounds (year, monthIndex) {
36
+ const start = new Date(year, monthIndex, 1)
37
+ const end = new Date(year, monthIndex + 1, 0)
38
+ start.setHours(0, 0, 0, 0)
39
+ end.setHours(0, 0, 0, 0)
40
+ return {
41
+ periodStart: start.getTime(),
42
+ periodEnd: end.getTime(),
43
+ }
44
+ }
45
+
46
+ /**
47
+ * ISO-week (Mon–Sun) period bounds as local midnights, returned as Unix ms.
48
+ * @param {Date | number} [asOf]
49
+ */
50
+ export function weekPeriodBounds (asOf = Date.now()) {
51
+ const ref = new Date(asOf)
52
+ ref.setHours(0, 0, 0, 0)
53
+ const day = ref.getDay() // 0 Sun … 6 Sat
54
+ const daysFromMonday = day === 0 ? 6 : day - 1
55
+ const start = new Date(ref)
56
+ start.setDate(ref.getDate() - daysFromMonday)
57
+ start.setHours(0, 0, 0, 0)
58
+ const end = new Date(start)
59
+ end.setDate(start.getDate() + 6)
60
+ end.setHours(0, 0, 0, 0)
61
+ return {
62
+ periodStart: start.getTime(),
63
+ periodEnd: end.getTime(),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Billing period that contains `asOf` for a contract's billing policy.
69
+ * @param {{ billingPeriod?: string } | null | undefined} contract
70
+ * @param {Date | number} [asOf]
71
+ */
72
+ export function periodForContract (contract, asOf = Date.now()) {
73
+ const billingPeriod = normalizeBillingPeriod(contract?.billingPeriod)
74
+ if (billingPeriod === 'weekly') return weekPeriodBounds(asOf)
75
+ const ref = new Date(asOf)
76
+ return monthPeriodBounds(ref.getFullYear(), ref.getMonth())
77
+ }
78
+
79
+ /**
80
+ * Contract is active on `asOf` when start ≤ asOf and (no end or end ≥ asOf day start).
81
+ * @param {{ startDate?: unknown, endDate?: unknown } | null | undefined} contract
82
+ * @param {Date | number} [asOf]
83
+ */
84
+ export function isContractActive (contract, asOf = Date.now()) {
85
+ if (!contract) return false
86
+ const asOfMs = toMs(asOf) ?? Date.now()
87
+ const dayStart = new Date(asOfMs)
88
+ dayStart.setHours(0, 0, 0, 0)
89
+ const dayMs = dayStart.getTime()
90
+
91
+ const startMs = toMs(contract.startDate)
92
+ if (startMs != null) {
93
+ const startDay = new Date(startMs)
94
+ startDay.setHours(0, 0, 0, 0)
95
+ if (dayMs < startDay.getTime()) return false
96
+ }
97
+
98
+ const endMs = toMs(contract.endDate)
99
+ if (endMs != null) {
100
+ const endDay = new Date(endMs)
101
+ endDay.setHours(0, 0, 0, 0)
102
+ if (dayMs > endDay.getTime()) return false
103
+ }
104
+
105
+ return true
106
+ }
107
+
108
+ /**
109
+ * Newest timesheet for a contract + periodStart pair.
110
+ * @param {Array<{ id: string, createdAt?: number, content?: object }>} resources
111
+ * @param {string} contractId
112
+ * @param {number} periodStart
113
+ */
114
+ export function findTimesheetForContractPeriod (resources, contractId, periodStart) {
115
+ if (!contractId || periodStart == null) return null
116
+ const matches = (resources || []).filter((r) => {
117
+ if (r.content?.periodStart !== periodStart) return false
118
+ const ref = optionalReference(r.content?.contractId)
119
+ return ref?.resourceId === contractId
120
+ })
121
+ if (matches.length === 0) return null
122
+ return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
123
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ findTimesheetForContractPeriod,
4
+ isContractActive,
5
+ normalizeBillingPeriod,
6
+ periodForContract,
7
+ weekPeriodBounds,
8
+ } from './billing-period.js'
9
+
10
+ describe('normalizeBillingPeriod', () => {
11
+ it('defaults unknown values to monthly', () => {
12
+ expect(normalizeBillingPeriod('weekly')).toBe('weekly')
13
+ expect(normalizeBillingPeriod('monthly')).toBe('monthly')
14
+ expect(normalizeBillingPeriod(undefined)).toBe('monthly')
15
+ expect(normalizeBillingPeriod('quarterly')).toBe('monthly')
16
+ })
17
+ })
18
+
19
+ describe('weekPeriodBounds', () => {
20
+ it('returns Mon–Sun containing the reference day', () => {
21
+ // Wednesday 2026-08-19 local
22
+ const { periodStart, periodEnd } = weekPeriodBounds(new Date(2026, 7, 19))
23
+ expect(new Date(periodStart).getDay()).toBe(1) // Monday
24
+ expect(new Date(periodEnd).getDay()).toBe(0) // Sunday
25
+ expect(new Date(periodStart).getDate()).toBe(17)
26
+ expect(new Date(periodEnd).getDate()).toBe(23)
27
+ })
28
+
29
+ it('treats Sunday as end of the current ISO week', () => {
30
+ const { periodStart, periodEnd } = weekPeriodBounds(new Date(2026, 7, 23))
31
+ expect(new Date(periodStart).getDate()).toBe(17)
32
+ expect(new Date(periodEnd).getDate()).toBe(23)
33
+ })
34
+ })
35
+
36
+ describe('periodForContract', () => {
37
+ it('uses calendar month when billingPeriod is monthly or missing', () => {
38
+ const asOf = new Date(2026, 7, 19)
39
+ expect(periodForContract({ billingPeriod: 'monthly' }, asOf)).toEqual({
40
+ periodStart: new Date(2026, 7, 1).setHours(0, 0, 0, 0),
41
+ periodEnd: new Date(2026, 7, 31).setHours(0, 0, 0, 0),
42
+ })
43
+ expect(periodForContract({}, asOf).periodStart).toBe(
44
+ new Date(2026, 7, 1).setHours(0, 0, 0, 0),
45
+ )
46
+ })
47
+
48
+ it('uses ISO week when billingPeriod is weekly', () => {
49
+ const asOf = new Date(2026, 7, 19)
50
+ expect(periodForContract({ billingPeriod: 'weekly' }, asOf)).toEqual(
51
+ weekPeriodBounds(asOf),
52
+ )
53
+ })
54
+ })
55
+
56
+ describe('isContractActive', () => {
57
+ it('requires start ≤ asOf and end ≥ asOf when set', () => {
58
+ const asOf = new Date(2026, 7, 19)
59
+ expect(isContractActive({
60
+ startDate: new Date(2026, 0, 1).getTime(),
61
+ endDate: new Date(2026, 11, 31).getTime(),
62
+ }, asOf)).toBe(true)
63
+
64
+ expect(isContractActive({
65
+ startDate: new Date(2026, 8, 1).getTime(),
66
+ }, asOf)).toBe(false)
67
+
68
+ expect(isContractActive({
69
+ startDate: new Date(2026, 0, 1).getTime(),
70
+ endDate: new Date(2026, 6, 1).getTime(),
71
+ }, asOf)).toBe(false)
72
+
73
+ expect(isContractActive({
74
+ startDate: new Date(2026, 0, 1).getTime(),
75
+ }, asOf)).toBe(true)
76
+ })
77
+ })
78
+
79
+ describe('findTimesheetForContractPeriod', () => {
80
+ it('matches contract id + period and returns the newest', () => {
81
+ const resources = [
82
+ { id: 'a', createdAt: 1, content: { periodStart: 100, contractId: 'c1' } },
83
+ { id: 'b', createdAt: 3, content: { periodStart: 100, contractId: { resourceId: 'c1' } } },
84
+ { id: 'c', createdAt: 9, content: { periodStart: 100, contractId: 'c2' } },
85
+ ]
86
+ expect(findTimesheetForContractPeriod(resources, 'c1', 100)?.id).toBe('b')
87
+ expect(findTimesheetForContractPeriod(resources, 'c1', 999)).toBe(null)
88
+ })
89
+ })
@@ -1,25 +1,12 @@
1
1
  import { CalendarClient } from '@ossy/calendar'
2
2
  import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
3
+ import { monthPeriodBounds } from './billing-period.js'
4
+
5
+ export { monthPeriodBounds }
3
6
 
4
7
  /** Prefill hours for a workday (SPEC). */
5
8
  export const WORKDAY_HOURS = 8
6
9
 
7
- /**
8
- * Calendar-month period bounds as local midnights, returned as Unix ms.
9
- * @param {number} year
10
- * @param {number} monthIndex 0–11
11
- */
12
- export function monthPeriodBounds (year, monthIndex) {
13
- const start = new Date(year, monthIndex, 1)
14
- const end = new Date(year, monthIndex + 1, 0)
15
- start.setHours(0, 0, 0, 0)
16
- end.setHours(0, 0, 0, 0)
17
- return {
18
- periodStart: start.getTime(),
19
- periodEnd: end.getTime(),
20
- }
21
- }
22
-
23
10
  /**
24
11
  * Build prefilled timesheet lines for an inclusive period.
25
12
  * Workdays → 8h; non-workdays → 0h. `isWorkday` is snapshotted.
@@ -0,0 +1,81 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { ResourcesEvents, commitResource } from '@ossy/resources/server'
3
+ import { TimesheetsSchema } from './schema-ids.js'
4
+ import { timesheets } from './locations.js'
5
+ import { buildPrefilledLines } from './build-prefilled-lines.js'
6
+ import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
7
+ import { toTimesheetDto } from './timesheet-dto.js'
8
+ import { optionalReference } from './timesheet-refs.js'
9
+
10
+ /**
11
+ * Create a draft timesheet resource (shared by manual generate and auto-generate).
12
+ *
13
+ * @param {object} options
14
+ * @param {string} options.workspaceId
15
+ * @param {number} options.periodStart
16
+ * @param {number} options.periodEnd
17
+ * @param {string} [options.locale]
18
+ * @param {unknown} [options.employeeId]
19
+ * @param {unknown} [options.contractId]
20
+ * @param {string} [options.createdBy]
21
+ * @param {boolean} [options.autoGenerated]
22
+ * @param {{ info?: Function }} [options.log]
23
+ */
24
+ export async function createTimesheetDraft ({
25
+ workspaceId,
26
+ periodStart,
27
+ periodEnd,
28
+ locale,
29
+ employeeId: employeeIdRaw,
30
+ contractId: contractIdRaw,
31
+ createdBy = 'system',
32
+ autoGenerated = false,
33
+ log,
34
+ } = {}) {
35
+ if (!workspaceId) {
36
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
37
+ }
38
+
39
+ periodStart = Number(periodStart)
40
+ periodEnd = Number(periodEnd)
41
+
42
+ if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd < periodStart) {
43
+ throw Object.assign(new Error('invalid period'), { status: 400 })
44
+ }
45
+
46
+ const holidayLocale = resolveHolidayLocale(locale, DEFAULT_HOLIDAY_LOCALE)
47
+ const employeeId = optionalReference(employeeIdRaw)
48
+ const contractId = optionalReference(contractIdRaw)
49
+
50
+ log?.info?.(
51
+ `[timesheets] create draft ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
52
+ )
53
+
54
+ const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
55
+ const content = {
56
+ periodStart,
57
+ periodEnd,
58
+ status: 'draft',
59
+ holidayLocale,
60
+ lines,
61
+ ...(employeeId ? { employeeId } : {}),
62
+ ...(contractId ? { contractId } : {}),
63
+ ...(autoGenerated ? { autoGenerated: true } : {}),
64
+ }
65
+
66
+ const resourceId = nanoid()
67
+ const event = ResourcesEvents.Created({
68
+ resourceId,
69
+ schemaId: TimesheetsSchema.timesheet,
70
+ createdBy,
71
+ belongsTo: workspaceId,
72
+ location: timesheets,
73
+ name: `timesheet-${periodStart}-${resourceId}.json`,
74
+ content,
75
+ })
76
+
77
+ const created = await commitResource(event)
78
+ log?.info?.(`[timesheets] Created timesheet ${resourceId}`)
79
+
80
+ return toTimesheetDto({ id: resourceId, content, ...created })
81
+ }
@@ -1,10 +1,5 @@
1
- import { nanoid } from 'nanoid'
2
- import { ResourcesEvents, commitResource } from '@ossy/resources/server'
3
- import { TimesheetsSchema } from './schema-ids.js'
4
- import { timesheets } from './locations.js'
5
- import { buildPrefilledLines, monthPeriodBounds } from './build-prefilled-lines.js'
6
- import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
7
- import { toTimesheetDto, optionalReference } from './timesheet-resources.js'
1
+ import { monthPeriodBounds } from './billing-period.js'
2
+ import { createTimesheetDraft } from './create-timesheet-draft.js'
8
3
 
9
4
  export const metadata = { id: '@ossy/timesheets/tasks/generate' }
10
5
 
@@ -37,46 +32,14 @@ export async function run ({ payload, req, log }) {
37
32
  ;({ periodStart, periodEnd } = monthPeriodBounds(Number(year), Number(monthIndex)))
38
33
  }
39
34
 
40
- periodStart = Number(periodStart)
41
- periodEnd = Number(periodEnd)
42
-
43
- if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd < periodStart) {
44
- throw Object.assign(new Error('invalid period'), { status: 400 })
45
- }
46
-
47
- const holidayLocale = resolveHolidayLocale(payloadLocale, DEFAULT_HOLIDAY_LOCALE)
48
-
49
- log?.info(
50
- `[timesheets/tasks/generate] period ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
51
- )
52
-
53
- const employeeId = optionalReference(payload?.employeeId)
54
- const contractId = optionalReference(payload?.contractId)
55
-
56
- const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
57
- const content = {
35
+ return createTimesheetDraft({
36
+ workspaceId,
58
37
  periodStart,
59
38
  periodEnd,
60
- status: 'draft',
61
- holidayLocale,
62
- lines,
63
- ...(employeeId ? { employeeId } : {}),
64
- ...(contractId ? { contractId } : {}),
65
- }
66
-
67
- const resourceId = nanoid()
68
- const event = ResourcesEvents.Created({
69
- resourceId,
70
- schemaId: TimesheetsSchema.timesheet,
39
+ locale: payloadLocale,
40
+ employeeId: payload?.employeeId,
41
+ contractId: payload?.contractId,
71
42
  createdBy: req?.userId ?? 'system',
72
- belongsTo: workspaceId,
73
- location: timesheets,
74
- name: `timesheet-${periodStart}-${resourceId}.json`,
75
- content,
43
+ log,
76
44
  })
77
-
78
- const created = await commitResource(event)
79
- log?.info(`[timesheets/tasks/generate] Created timesheet ${resourceId}`)
80
-
81
- return toTimesheetDto({ id: resourceId, content, ...created })
82
45
  }
package/src/index.js CHANGED
@@ -16,6 +16,15 @@ export { downloadTimesheetCsv } from './download-timesheet-csv.js'
16
16
  export { downloadTimesheetImage } from './download-timesheet-image.js'
17
17
  export { downloadTimesheetPdf } from './download-timesheet-pdf.js'
18
18
  export { buildPrefilledLines, monthPeriodBounds, WORKDAY_HOURS } from './build-prefilled-lines.js'
19
+ export {
20
+ BILLING_PERIODS,
21
+ DEFAULT_BILLING_PERIOD,
22
+ findTimesheetForContractPeriod,
23
+ isContractActive,
24
+ normalizeBillingPeriod,
25
+ periodForContract,
26
+ weekPeriodBounds,
27
+ } from './billing-period.js'
19
28
  export { resolveHolidayLocale, DEFAULT_HOLIDAY_LOCALE, HOLIDAY_COUNTRY_CODES } from './holiday-locale.js'
20
29
  export { Timesheet } from './Timesheet.jsx'
21
30
  export { TimesheetExportCard } from './TimesheetExportCard.jsx'
@@ -0,0 +1,76 @@
1
+ import { viewResource } from '@ossy/resources/server'
2
+ import { TimesheetsSchema } from './schema-ids.js'
3
+ import TimesheetReminderEmail, { subject as assignedSubject } from './timesheet-reminder.email.jsx'
4
+ import {
5
+ formatPeriodLabel,
6
+ resolveTimesheetReminderRecipient,
7
+ shouldNotifyTimesheetCreated,
8
+ timesheetAssignedUrl,
9
+ } from './timesheet-reminder.js'
10
+ import { optionalReference } from './timesheet-refs.js'
11
+
12
+ export const metadata = {
13
+ id: '@ossy/timesheets/tasks/send-reminder',
14
+ triggers: [
15
+ { type: TimesheetsSchema.timesheet, event: 'Created' },
16
+ ],
17
+ }
18
+
19
+ /**
20
+ * Email the linked employee when an auto-generated timesheet is created.
21
+ * Manual generate does not notify. Created fires once, so there is no daily cron.
22
+ */
23
+ export async function run ({ event, log, integrations }) {
24
+ if (!shouldNotifyTimesheetCreated(event)) {
25
+ return { sent: 0, skipped: 1 }
26
+ }
27
+
28
+ const content = event.payload?.content ?? {}
29
+ const resourceId = event.resourceId
30
+ const timesheet = { id: resourceId, belongsTo: event.payload?.belongsTo, content }
31
+
32
+ const employeeId = optionalReference(content.employeeId)?.resourceId
33
+ const contractId = optionalReference(content.contractId)?.resourceId
34
+
35
+ const [employee, contract] = await Promise.all([
36
+ employeeId ? viewResource(employeeId).catch(() => null) : null,
37
+ contractId ? viewResource(contractId).catch(() => null) : null,
38
+ ])
39
+
40
+ const recipient = resolveTimesheetReminderRecipient(
41
+ employee ? [employee] : [],
42
+ timesheet,
43
+ )
44
+ if (!recipient) {
45
+ log?.warn(`[timesheets/tasks/send-reminder] no employee email for timesheet ${resourceId}`)
46
+ return { sent: 0, skipped: 1 }
47
+ }
48
+
49
+ const emailClient = integrations?.get?.('email')
50
+ if (!emailClient) {
51
+ log?.warn('[timesheets/tasks/send-reminder] No email integration available, skipping')
52
+ return { sent: 0, skipped: 1 }
53
+ }
54
+
55
+ const clientName = contract?.content?.clientName ?? null
56
+ const periodLabel = formatPeriodLabel(content.periodStart, content.periodEnd)
57
+ const timesheetsUrl = timesheetAssignedUrl(resourceId)
58
+
59
+ await emailClient.sendTemplate(
60
+ TimesheetReminderEmail,
61
+ {
62
+ employeeName: recipient.name,
63
+ clientName,
64
+ periodLabel,
65
+ timesheetsUrl,
66
+ },
67
+ {
68
+ to: recipient.email,
69
+ from: 'noreply@ossy.se',
70
+ subject: assignedSubject,
71
+ },
72
+ )
73
+
74
+ log?.info(`[timesheets/tasks/send-reminder] Assigned-timesheet email sent to ${recipient.email}`)
75
+ return { sent: 1, skipped: 0 }
76
+ }
@@ -0,0 +1,26 @@
1
+ import { optionalReference } from './timesheet-refs.js'
2
+
3
+ export { optionalReference } from './timesheet-refs.js'
4
+
5
+ /** Newest sheet for a period. Generate always creates a new resource; use get-by-id for a specific sheet. */
6
+ export function findTimesheetForPeriod (resources, periodStart) {
7
+ const matches = (resources || []).filter((r) => r.content?.periodStart === periodStart)
8
+ if (matches.length === 0) return null
9
+ return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
10
+ }
11
+
12
+ export function toTimesheetDto (resource) {
13
+ if (!resource) return null
14
+ return {
15
+ id: resource.id,
16
+ periodStart: resource.content?.periodStart,
17
+ periodEnd: resource.content?.periodEnd,
18
+ status: resource.content?.status ?? 'draft',
19
+ holidayLocale: resource.content?.holidayLocale ?? null,
20
+ employeeId: optionalReference(resource.content?.employeeId),
21
+ contractId: optionalReference(resource.content?.contractId),
22
+ lines: resource.content?.lines ?? [],
23
+ createdAt: resource.createdAt,
24
+ updatedAt: resource.updatedAt,
25
+ }
26
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Normalize an optional document reference to `{ resourceId }` or `null`.
3
+ * Accepts a stored object, a plain id string, or empty.
4
+ */
5
+ export function optionalReference (value) {
6
+ if (value == null || value === '') return null
7
+ if (typeof value === 'string') {
8
+ const id = value.trim()
9
+ return id ? { resourceId: id } : null
10
+ }
11
+ if (typeof value === 'object' && typeof value.resourceId === 'string') {
12
+ const id = value.resourceId.trim()
13
+ return id ? { resourceId: id } : null
14
+ }
15
+ return null
16
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react'
2
+ import { EmailLayout, EmailButton, EmailText } from '@ossy/email'
3
+
4
+ export const id = '@ossy/timesheets/emails/reminder'
5
+ export const subject = 'En tidrapport har skapats åt dig'
6
+
7
+ export default function TimesheetReminderEmail ({
8
+ employeeName,
9
+ clientName,
10
+ periodLabel,
11
+ timesheetsUrl,
12
+ }) {
13
+ const greeting = employeeName ? `Hej ${employeeName},` : 'Hej,'
14
+
15
+ return (
16
+ <EmailLayout>
17
+ <h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
18
+ Du har tilldelats en tidrapport
19
+ </h1>
20
+
21
+ <EmailText>{greeting}</EmailText>
22
+
23
+ <EmailText>
24
+ En tidrapport har skapats åt dig
25
+ {clientName ? (
26
+ <>
27
+ {' '}för uppdraget <strong>{clientName}</strong>
28
+ </>
29
+ ) : null}
30
+ {periodLabel ? (
31
+ <>
32
+ {' '}för perioden <strong>{periodLabel}</strong>
33
+ </>
34
+ ) : null}
35
+ . Kontrollera timmarna och spara när de stämmer.
36
+ </EmailText>
37
+
38
+ {timesheetsUrl ? (
39
+ <EmailButton href={timesheetsUrl}>Öppna tidrapporten</EmailButton>
40
+ ) : null}
41
+
42
+ <EmailText style={{ color: '#888888', fontSize: 13 }}>
43
+ Du får det här mejlet för att en tidrapport skapades automatiskt utifrån ditt avtal.
44
+ </EmailText>
45
+ </EmailLayout>
46
+ )
47
+ }
@@ -0,0 +1,62 @@
1
+ import { isEmailLike } from '@ossy/schema'
2
+ import { optionalReference } from './timesheet-refs.js'
3
+
4
+ /**
5
+ * Whether a timesheet Created event should notify the assigned employee.
6
+ * Manual generate does not email; auto-generate does, once, on create.
7
+ *
8
+ * @param {{ payload?: { content?: object }, resourceId?: string } | null | undefined} event
9
+ */
10
+ export function shouldNotifyTimesheetCreated (event) {
11
+ const content = event?.payload?.content ?? {}
12
+ return content.autoGenerated === true && Boolean(event?.resourceId)
13
+ }
14
+
15
+ /**
16
+ * Resolve reminder recipient from the linked employee resource.
17
+ *
18
+ * @param {Array<{ id: string, content?: { name?: string, email?: string } }>} employees
19
+ * @param {{ content?: { employeeId?: unknown } }} timesheet
20
+ * @returns {{ email: string, name: string | null } | null}
21
+ */
22
+ export function resolveTimesheetReminderRecipient (employees, timesheet) {
23
+ const employeeId = optionalReference(timesheet?.content?.employeeId)?.resourceId
24
+ if (!employeeId) return null
25
+
26
+ const employee = (employees || []).find((e) => e.id === employeeId)
27
+ const email = employee?.content?.email?.trim?.() ?? employee?.content?.email
28
+ if (!isEmailLike(email)) return null
29
+
30
+ return {
31
+ email: String(email).trim(),
32
+ name: employee?.content?.name ?? null,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Public timesheets home URL, optionally opening a specific sheet (`?r=`).
38
+ * Swedish email copy uses `/tidrapporter`.
39
+ *
40
+ * @param {string} [resourceId]
41
+ * @param {string} [origin]
42
+ */
43
+ export function timesheetAssignedUrl (resourceId, origin = process.env.WEB_CLIENT_DOMAIN) {
44
+ const base = String(origin || '').replace(/\/$/, '')
45
+ if (!base) return null
46
+ const path = '/tidrapporter'
47
+ if (!resourceId) return `${base}${path}`
48
+ return `${base}${path}?r=${encodeURIComponent(resourceId)}`
49
+ }
50
+
51
+ /**
52
+ * Format a period for email copy (local calendar dates).
53
+ * @param {number} periodStart
54
+ * @param {number} periodEnd
55
+ * @param {string} [locale]
56
+ */
57
+ export function formatPeriodLabel (periodStart, periodEnd, locale = 'sv-SE') {
58
+ const start = new Date(periodStart)
59
+ const end = new Date(periodEnd)
60
+ const opts = { year: 'numeric', month: 'long', day: 'numeric' }
61
+ return `${start.toLocaleDateString(locale, opts)} – ${end.toLocaleDateString(locale, opts)}`
62
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ formatPeriodLabel,
4
+ resolveTimesheetReminderRecipient,
5
+ shouldNotifyTimesheetCreated,
6
+ timesheetAssignedUrl,
7
+ } from './timesheet-reminder.js'
8
+
9
+ describe('shouldNotifyTimesheetCreated', () => {
10
+ it('notifies only auto-generated creates', () => {
11
+ expect(shouldNotifyTimesheetCreated({
12
+ resourceId: 'sheet-1',
13
+ payload: { content: { autoGenerated: true } },
14
+ })).toBe(true)
15
+
16
+ expect(shouldNotifyTimesheetCreated({
17
+ resourceId: 'sheet-2',
18
+ payload: { content: { autoGenerated: false } },
19
+ })).toBe(false)
20
+
21
+ expect(shouldNotifyTimesheetCreated({
22
+ resourceId: 'sheet-3',
23
+ payload: { content: {} },
24
+ })).toBe(false)
25
+
26
+ expect(shouldNotifyTimesheetCreated({
27
+ payload: { content: { autoGenerated: true } },
28
+ })).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('resolveTimesheetReminderRecipient', () => {
33
+ it('requires a linked employee with a valid email', () => {
34
+ const employees = [
35
+ { id: 'emp-1', content: { name: 'Ada', email: 'ada@example.com' } },
36
+ { id: 'emp-2', content: { name: 'Bob', email: 'not-an-email' } },
37
+ ]
38
+
39
+ expect(resolveTimesheetReminderRecipient(employees, {
40
+ content: { employeeId: 'emp-1' },
41
+ })).toEqual({ email: 'ada@example.com', name: 'Ada' })
42
+
43
+ expect(resolveTimesheetReminderRecipient(employees, {
44
+ content: { employeeId: 'emp-2' },
45
+ })).toBe(null)
46
+
47
+ expect(resolveTimesheetReminderRecipient(employees, {
48
+ content: {},
49
+ })).toBe(null)
50
+ })
51
+ })
52
+
53
+ describe('timesheetAssignedUrl', () => {
54
+ it('builds the Swedish home URL with an optional sheet query', () => {
55
+ expect(timesheetAssignedUrl(undefined, 'https://app.ossy.se/')).toBe(
56
+ 'https://app.ossy.se/tidrapporter',
57
+ )
58
+ expect(timesheetAssignedUrl('sheet-1', 'https://app.ossy.se')).toBe(
59
+ 'https://app.ossy.se/tidrapporter?r=sheet-1',
60
+ )
61
+ expect(timesheetAssignedUrl('sheet-1', '')).toBe(null)
62
+ })
63
+ })
64
+
65
+ describe('formatPeriodLabel', () => {
66
+ it('formats inclusive local dates', () => {
67
+ const label = formatPeriodLabel(
68
+ new Date(2026, 7, 1).getTime(),
69
+ new Date(2026, 7, 31).getTime(),
70
+ 'en-US',
71
+ )
72
+ expect(label).toContain('2026')
73
+ expect(label).toContain('–')
74
+ })
75
+ })
@@ -1,5 +1,11 @@
1
1
  import { ResourcesQueries } from '@ossy/resources/server'
2
2
 
3
+ export {
4
+ optionalReference,
5
+ findTimesheetForPeriod,
6
+ toTimesheetDto,
7
+ } from './timesheet-dto.js'
8
+
3
9
  /**
4
10
  * List timesheet resources by schema id (and optional workspace).
5
11
  */
@@ -8,43 +14,3 @@ export function getTimesheetResources ({ schemaId, belongsTo } = {}) {
8
14
  if (belongsTo) query.belongsTo = belongsTo
9
15
  return ResourcesQueries.GetResources(query)
10
16
  }
11
-
12
- /** Newest sheet for a period. Generate always creates a new resource; use get-by-id for a specific sheet. */
13
- export function findTimesheetForPeriod (resources, periodStart) {
14
- const matches = (resources || []).filter((r) => r.content?.periodStart === periodStart)
15
- if (matches.length === 0) return null
16
- return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
17
- }
18
-
19
- /**
20
- * Normalize an optional document reference to `{ resourceId }` or `null`.
21
- * Accepts a stored object, a plain id string, or empty.
22
- */
23
- export function optionalReference (value) {
24
- if (value == null || value === '') return null
25
- if (typeof value === 'string') {
26
- const id = value.trim()
27
- return id ? { resourceId: id } : null
28
- }
29
- if (typeof value === 'object' && typeof value.resourceId === 'string') {
30
- const id = value.resourceId.trim()
31
- return id ? { resourceId: id } : null
32
- }
33
- return null
34
- }
35
-
36
- export function toTimesheetDto (resource) {
37
- if (!resource) return null
38
- return {
39
- id: resource.id,
40
- periodStart: resource.content?.periodStart,
41
- periodEnd: resource.content?.periodEnd,
42
- status: resource.content?.status ?? 'draft',
43
- holidayLocale: resource.content?.holidayLocale ?? null,
44
- employeeId: optionalReference(resource.content?.employeeId),
45
- contractId: optionalReference(resource.content?.contractId),
46
- lines: resource.content?.lines ?? [],
47
- createdAt: resource.createdAt,
48
- updatedAt: resource.updatedAt,
49
- }
50
- }
@@ -3,7 +3,7 @@ import {
3
3
  findTimesheetForPeriod,
4
4
  optionalReference,
5
5
  toTimesheetDto,
6
- } from './timesheet-resources.js'
6
+ } from './timesheet-dto.js'
7
7
 
8
8
  describe('optionalReference', () => {
9
9
  it('normalizes ids, objects, and empty values', () => {
@@ -38,6 +38,11 @@ export default {
38
38
  of: TimesheetRefs.contract,
39
39
  description: 'Optional contract this timesheet belongs to',
40
40
  },
41
+ {
42
+ name: 'autoGenerated',
43
+ type: 'boolean',
44
+ description: 'Set when the daily auto-generate cron created this sheet',
45
+ },
41
46
  // `lines` is stored in content (array of day rows) — same nested-content pattern as booking availability.
42
47
  ],
43
48
  }