@fabriktor/fx 0.0.114 → 0.0.116

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.
@@ -1,6 +1,7 @@
1
1
  import { type BillableOperator } from "@fabriktor/client";
2
2
  import { type Bill, BillableFolder, Charge, Equipment, Item, LaborRate, MeterReading, Quantifier, Tax, TmpBillNumber, VendorInvoice, Warehouse } from "@fabriktor/schema";
3
- import type { ContactsFXOperator, EmploymentOwnerScope } from "../contacts/contacts.js";
3
+ import type { ContactsFXOperator, EmploymentOwnerScopeOptions } from "../contacts/contacts.js";
4
+ import { type EmploymentReadContext } from "../contacts/scope.js";
4
5
  import type { MultiCreateCheckpoint } from "../core/multi-create.js";
5
6
  import { type OwnerSearchOptions, type SearchOptions, type SearchPage, type SearchQuery } from "../core/search.js";
6
7
  type GenerateBillPayload = Parameters<BillableOperator["generateBill"]>[0];
@@ -98,16 +99,8 @@ export type BillableFXOptions = {
98
99
  billable: BillableOperator;
99
100
  contacts_fx: ContactsFXOperator;
100
101
  };
101
- export type LoadBillableReadContextOptions = {
102
- owner_id: string;
103
- uid: string;
104
- active_mode: string;
105
- };
106
- export type BillableReadContext = {
107
- owner_id: string;
108
- active_mode: string;
109
- employment: EmploymentOwnerScope;
110
- };
102
+ export type LoadBillableReadContextOptions = EmploymentOwnerScopeOptions;
103
+ export type BillableReadContext = EmploymentReadContext;
111
104
  export declare const BillableReadResource: {
112
105
  readonly Bill: "bill";
113
106
  readonly BillableFolder: "billable_folder";
@@ -5,6 +5,8 @@
5
5
  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
6
  import { mergeQueryOptions, queryNestedParam, queryParams, } from "@fabriktor/client";
7
7
  import { BillTypeDynamic, BillTypeFixed, UserTypeEmployee, UserTypeEmployer, } from "@fabriktor/schema";
8
+ import { employmentActivityOwnerIDs, employmentCatalogOwnerIDs as catalogOwnerIDs, employmentEmployerIDs, employmentManagesRecord, employmentOwnsRecord, loadEmploymentReadContext, } from "../contacts/scope.js";
9
+ import { restrictOwnerIDs } from "../core/scope.js";
8
10
  import { isZeroDateTime } from "../core/date.js";
9
11
  import { errRequired, errResolveFailed, errValidation } from "../core/err.js";
10
12
  import { requiredOperationID, resolvedID } from "../core/id.js";
@@ -109,23 +111,7 @@ export class BillableFX {
109
111
  this.contacts_fx = opts.contacts_fx;
110
112
  }
111
113
  async loadReadContext(opts) {
112
- const owner_id = opts.owner_id.trim();
113
- if (owner_id === "") {
114
- throw errRequired({
115
- field: "owner_id",
116
- msg: msgOwnerIDRequired,
117
- });
118
- }
119
- const employment = await this.contacts_fx.employmentOwnerScope({
120
- owner_id,
121
- uid: opts.uid,
122
- active_mode: opts.active_mode,
123
- });
124
- return {
125
- owner_id,
126
- active_mode: opts.active_mode,
127
- employment,
128
- };
114
+ return await loadEmploymentReadContext(this.contacts_fx, opts);
129
115
  }
130
116
  async searchBills(opts) {
131
117
  return await runOwnerSearch({
@@ -175,12 +161,12 @@ export class BillableFX {
175
161
  });
176
162
  }
177
163
  async searchReadableCharges(opts) {
178
- const employer_ids = activityEmployerScopeIDs(opts);
164
+ const employer_ids = employmentEmployerIDs(opts.context, opts.employer_scope_ids);
179
165
  return await this.searchReadable({
180
166
  ...opts,
181
167
  // owner_id identifies the creator; employer_id identifies their employer.
182
168
  // An explicitly empty authorized employer set must not become All.
183
- allowed_owner_ids: employer_ids?.size === 0 ? [] : activityOwnerIDs(opts.context),
169
+ allowed_owner_ids: employer_ids?.length === 0 ? [] : employmentActivityOwnerIDs(opts.context, true),
184
170
  query: mergeQueryOptions(opts.query, chargeSearchQuery(opts), employer_ids === undefined ? undefined : queryParams({
185
171
  employer_id: [...employer_ids],
186
172
  })),
@@ -284,11 +270,11 @@ export class BillableFX {
284
270
  });
285
271
  }
286
272
  async searchReadableMeterReadings(opts) {
287
- const employer_ids = activityEmployerScopeIDs(opts);
273
+ const employer_ids = employmentEmployerIDs(opts.context, opts.employer_scope_ids);
288
274
  return await this.searchReadable({
289
275
  ...opts,
290
276
  // An explicitly empty authorized scope must stay empty, not become All.
291
- allowed_owner_ids: employer_ids?.size === 0 ? [] : activityOwnerIDs(opts.context),
277
+ allowed_owner_ids: employer_ids?.length === 0 ? [] : employmentActivityOwnerIDs(opts.context, true),
292
278
  query: mergeQueryOptions(opts.query, meterReadingSearchQuery(opts), employer_ids === undefined ? undefined : queryParams({
293
279
  employer_id: [...employer_ids],
294
280
  })),
@@ -743,28 +729,14 @@ export function billableCanDeleteRecord(context, resource, record) {
743
729
  return billableCanWriteRecord(context, resource, record);
744
730
  }
745
731
  export function billableOwnsRecord(context, record) {
746
- return record !== null && record !== undefined && record.owner_id === context.owner_id;
732
+ return employmentOwnsRecord(context, record);
747
733
  }
748
734
  export function billableCatalogOwnerIDs(context) {
749
735
  return catalogOwnerIDs(context);
750
736
  }
751
737
  function billableRecordBelongsToActor(context, resource, record) {
752
- if (record === null || record === undefined) {
753
- return false;
754
- }
755
- if (record.owner_id === context.owner_id) {
756
- return true;
757
- }
758
- return (context.active_mode === UserTypeEmployer &&
759
- (resource === BillableReadResource.Charge ||
760
- resource === BillableReadResource.MeterReading) &&
761
- record.employer_id === context.owner_id);
762
- }
763
- function catalogOwnerIDs(context, employer_scope_ids) {
764
- if (context.active_mode === UserTypeEmployee) {
765
- return restrictOwnerIDs(context.employment.active_employer_ids, employer_scope_ids);
766
- }
767
- return uniqueIDs([context.owner_id]);
738
+ return employmentOwnsRecord(context, record) || ((resource === BillableReadResource.Charge || resource === BillableReadResource.MeterReading) &&
739
+ employmentManagesRecord(context, record));
768
740
  }
769
741
  function itemSearchQuery(opts) {
770
742
  return mergeQueryOptions(queryParams({
@@ -815,33 +787,6 @@ function meterReadingSearchQuery(opts) {
815
787
  : { equipment_id: resolvedID(opts.equipment_id) }),
816
788
  });
817
789
  }
818
- function activityEmployerScopeIDs(opts) {
819
- if (opts.context.active_mode === UserTypeEmployer) {
820
- return new Set([opts.context.owner_id]);
821
- }
822
- if (opts.context.active_mode !== UserTypeEmployee ||
823
- opts.employer_scope_ids === undefined) {
824
- return undefined;
825
- }
826
- return new Set(restrictOwnerIDs(opts.context.employment.active_employer_ids, opts.employer_scope_ids));
827
- }
828
- function activityOwnerIDs(context) {
829
- if (context.active_mode === UserTypeEmployer) {
830
- return uniqueIDs([context.owner_id, ...context.employment.related_employee_ids]);
831
- }
832
- return uniqueIDs([context.owner_id]);
833
- }
834
- function restrictOwnerIDs(allowed, requested) {
835
- const allowed_ids = uniqueIDs(allowed);
836
- if (requested === undefined) {
837
- return allowed_ids;
838
- }
839
- const allowed_set = new Set(allowed_ids);
840
- return uniqueIDs(requested).filter((id) => allowed_set.has(id));
841
- }
842
- function uniqueIDs(ids) {
843
- return [...new Set([...ids].map((id) => id.trim()).filter((id) => id !== ""))];
844
- }
845
790
  export function newBillableFX(opts) {
846
791
  return new BillableFX(opts);
847
792
  }
@@ -6,7 +6,8 @@
6
6
  import { queryParam, } from "@fabriktor/client";
7
7
  import { Friend, Hired, IDKey, Na, OldHire, UserTypeEmployee, UserTypeEmployer, } from "@fabriktor/schema";
8
8
  import { errRequired, errResolveFailed, errValidation } from "../core/err.js";
9
- import { requiredOperationID } from "../core/id.js";
9
+ import { normalizeIDs, requiredOperationID } from "../core/id.js";
10
+ import { mergeRecordsByID } from "../core/scope.js";
10
11
  import { runSearch } from "../core/search.js";
11
12
  const presenceIntervalMS = 120_000;
12
13
  const employmentUserBatchSize = 30;
@@ -245,7 +246,7 @@ export class ContactsFX {
245
246
  });
246
247
  }
247
248
  async usersByIDs(uid, ids) {
248
- const values = uniqueIDs(ids);
249
+ const values = normalizeIDs(ids);
249
250
  if (values.length === 0) {
250
251
  return [];
251
252
  }
@@ -257,13 +258,7 @@ export class ContactsFX {
257
258
  uid,
258
259
  }),
259
260
  })));
260
- const users = new Map();
261
- for (const page of pages) {
262
- for (const user of page.results) {
263
- users.set(user.id, user);
264
- }
265
- }
266
- return [...users.values()];
261
+ return mergeRecordsByID(pages.map((page) => page.results));
267
262
  }
268
263
  async findContact(id) {
269
264
  const out = await this.contacts.findOneContact({
@@ -290,9 +285,6 @@ export function emptyEmploymentOwnerScope() {
290
285
  related_employee_ids: [],
291
286
  };
292
287
  }
293
- function uniqueIDs(ids) {
294
- return [...new Set([...ids].map((id) => id.trim()).filter((id) => id !== ""))];
295
- }
296
288
  function chunks(values, size) {
297
289
  const out = [];
298
290
  for (let i = 0; i < values.length; i += size) {
@@ -0,0 +1,14 @@
1
+ import { type Owner, type User, type WeekSheet } from "@fabriktor/schema";
2
+ import type { ContactsFXOperator, EmploymentOwnerScope, EmploymentOwnerScopeOptions } from "./contacts.js";
3
+ export type EmploymentActor = Owner & Pick<User, "active_mode">;
4
+ export type EmploymentReadContext = EmploymentActor & {
5
+ employment: EmploymentOwnerScope;
6
+ };
7
+ export declare function loadEmploymentReadContext(contacts: ContactsFXOperator, opts: EmploymentOwnerScopeOptions): Promise<EmploymentReadContext>;
8
+ export declare function employmentCanRead(context: EmploymentActor): boolean;
9
+ export declare function employmentOwnsRecord(context: EmploymentActor, record: Owner | null | undefined): boolean;
10
+ export declare function employmentManagesRecord(context: EmploymentActor, record: Partial<Pick<WeekSheet, "employer_id">> | null | undefined): boolean;
11
+ export declare function employmentEmployeeIDs(context: EmploymentReadContext): string[];
12
+ export declare function employmentActivityOwnerIDs(context: EmploymentReadContext, include_employer?: boolean): string[];
13
+ export declare function employmentEmployerIDs(context: EmploymentReadContext, requested?: Iterable<string>): string[] | undefined;
14
+ export declare function employmentCatalogOwnerIDs(context: EmploymentReadContext, requested?: Iterable<string>): string[];
@@ -0,0 +1,49 @@
1
+ import { UserTypeEmployee, UserTypeEmployer, } from "@fabriktor/schema";
2
+ import { resolvedID } from "../core/id.js";
3
+ import { restrictOwnerIDs } from "../core/scope.js";
4
+ export async function loadEmploymentReadContext(contacts, opts) {
5
+ const owner_id = opts.owner_id.trim();
6
+ const employment = await contacts.employmentOwnerScope({ ...opts, owner_id });
7
+ return { owner_id, active_mode: opts.active_mode, employment };
8
+ }
9
+ export function employmentCanRead(context) {
10
+ return resolvedID(context.owner_id) !== undefined && (context.active_mode === UserTypeEmployee || context.active_mode === UserTypeEmployer);
11
+ }
12
+ export function employmentOwnsRecord(context, record) {
13
+ return record != null && record.owner_id === context.owner_id;
14
+ }
15
+ export function employmentManagesRecord(context, record) {
16
+ return record != null && context.active_mode === UserTypeEmployer &&
17
+ record.employer_id === context.owner_id;
18
+ }
19
+ export function employmentEmployeeIDs(context) {
20
+ return employmentCanRead(context) && context.active_mode === UserTypeEmployer
21
+ ? restrictOwnerIDs(context.employment.related_employee_ids)
22
+ : [];
23
+ }
24
+ export function employmentActivityOwnerIDs(context, include_employer = false) {
25
+ if (!employmentCanRead(context))
26
+ return [];
27
+ return context.active_mode === UserTypeEmployer
28
+ ? restrictOwnerIDs([
29
+ ...(include_employer ? [context.owner_id] : []),
30
+ ...employmentEmployeeIDs(context),
31
+ ])
32
+ : restrictOwnerIDs([context.owner_id]);
33
+ }
34
+ export function employmentEmployerIDs(context, requested) {
35
+ if (!employmentCanRead(context))
36
+ return [];
37
+ if (context.active_mode === UserTypeEmployer)
38
+ return restrictOwnerIDs([context.owner_id], requested);
39
+ return requested === undefined
40
+ ? undefined
41
+ : restrictOwnerIDs(context.employment.active_employer_ids, requested);
42
+ }
43
+ export function employmentCatalogOwnerIDs(context, requested) {
44
+ if (!employmentCanRead(context))
45
+ return [];
46
+ return context.active_mode === UserTypeEmployee
47
+ ? restrictOwnerIDs(context.employment.active_employer_ids, requested)
48
+ : restrictOwnerIDs([context.owner_id]);
49
+ }
@@ -1,3 +1,4 @@
1
1
  import { type OperationResult } from "@fabriktor/schema";
2
2
  export declare function requiredOperationID(out: OperationResult, msg: string): string;
3
3
  export declare function resolvedID(value?: string): string | undefined;
4
+ export declare function normalizeIDs(ids: Iterable<string>): string[];
@@ -22,3 +22,12 @@ export function resolvedID(value) {
22
22
  }
23
23
  return id;
24
24
  }
25
+ export function normalizeIDs(ids) {
26
+ const values = new Set();
27
+ for (const raw of ids) {
28
+ const id = resolvedID(raw);
29
+ if (id !== undefined)
30
+ values.add(id);
31
+ }
32
+ return [...values];
33
+ }
@@ -0,0 +1,9 @@
1
+ import type { FieldOf, OperationResultOf, QueryOptions, QueryOptionsCRUD } from "@fabriktor/client";
2
+ import { type IdentifiedRecord } from "./scope.js";
3
+ export type OwnerScopeQueryOptions<T> = QueryOptionsCRUD & {
4
+ owner_ids: Iterable<string>;
5
+ fetch: (opts: QueryOptionsCRUD) => Promise<OperationResultOf<T[]>>;
6
+ };
7
+ export declare function withOwnerQuery(owner_id: string, query?: QueryOptions): QueryOptions;
8
+ export declare function queryRecordParams<T>(field: FieldOf<T>, query: QueryOptions): QueryOptions;
9
+ export declare function runOwnerScopeQuery<T extends IdentifiedRecord>(opts: OwnerScopeQueryOptions<T>): Promise<T[]>;
@@ -0,0 +1,15 @@
1
+ import { OwnerIDKey } from "@fabriktor/schema";
2
+ import { mapOwnerScope, mergeRecordsByID } from "./scope.js";
3
+ export function withOwnerQuery(owner_id, query) {
4
+ return { ...query, [OwnerIDKey]: owner_id };
5
+ }
6
+ export function queryRecordParams(field, query) {
7
+ return Object.fromEntries(Object.entries(query).map(([key, value]) => [`${field}.${key}`, value]));
8
+ }
9
+ export async function runOwnerScopeQuery(opts) {
10
+ const responses = await mapOwnerScope(opts.owner_ids, (owner_id) => opts.fetch({
11
+ query: withOwnerQuery(owner_id, opts.query),
12
+ signal: opts.signal,
13
+ }), opts.signal);
14
+ return mergeRecordsByID(responses.map((response) => response.value ?? []));
15
+ }
@@ -0,0 +1,5 @@
1
+ import { IDKey, type Common } from "@fabriktor/schema";
2
+ export type IdentifiedRecord = Pick<Common, typeof IDKey>;
3
+ export declare function restrictOwnerIDs(allowed: Iterable<string>, requested?: Iterable<string>): string[];
4
+ export declare function mapOwnerScope<T>(owner_ids: Iterable<string>, fetch: (owner_id: string) => Promise<T>, signal?: AbortSignal): Promise<T[]>;
5
+ export declare function mergeRecordsByID<T extends IdentifiedRecord>(groups: Iterable<Iterable<T>>): T[];
@@ -0,0 +1,23 @@
1
+ import { IDKey } from "@fabriktor/schema";
2
+ import { normalizeIDs } from "./id.js";
3
+ export function restrictOwnerIDs(allowed, requested) {
4
+ const ids = normalizeIDs(allowed);
5
+ if (requested === undefined)
6
+ return ids;
7
+ const allowed_ids = new Set(ids);
8
+ return normalizeIDs(requested).filter((id) => allowed_ids.has(id));
9
+ }
10
+ export async function mapOwnerScope(owner_ids, fetch, signal) {
11
+ signal?.throwIfAborted();
12
+ const results = await Promise.all(normalizeIDs(owner_ids).map(fetch));
13
+ signal?.throwIfAborted();
14
+ return results;
15
+ }
16
+ export function mergeRecordsByID(groups) {
17
+ const values = new Map();
18
+ for (const group of groups) {
19
+ for (const value of group)
20
+ values.set(value[IDKey], value);
21
+ }
22
+ return [...values.values()];
23
+ }
@@ -1,4 +1,6 @@
1
1
  import type { OperationResultOf, SearchManyOptionsCRUD, SearchResultOf } from "@fabriktor/client";
2
+ import { type IdentifiedRecord } from "./scope.js";
3
+ export { withOwnerQuery } from "./query.js";
2
4
  export type SearchQuery = NonNullable<SearchManyOptionsCRUD["query"]>;
3
5
  export type SearchPaginationOptions = {
4
6
  page?: number;
@@ -42,14 +44,9 @@ export type SearchPage<T> = {
42
44
  done: boolean;
43
45
  };
44
46
  export declare function collectSearchResults<T>(search: (page: number) => Promise<SearchPage<T>>): Promise<T[]>;
45
- export declare function withOwnerQuery(owner_id: string, query?: SearchQuery): SearchQuery;
46
47
  export declare function runOwnerSearch<T>(opts: RunOwnerSearchOptions<T>): Promise<SearchPage<T>>;
47
- export declare function runOwnerScopeSearch<T extends {
48
- id: string;
49
- }>(opts: OwnerScopeSearchOptions<T>): Promise<SearchPage<T>>;
50
- export declare function runOwnerScopeFilteredSearch<T extends {
51
- id: string;
52
- }>(opts: OwnerScopeFilteredSearchOptions<T>): Promise<SearchPage<T>>;
48
+ export declare function runOwnerScopeSearch<T extends IdentifiedRecord>(opts: OwnerScopeSearchOptions<T>): Promise<SearchPage<T>>;
49
+ export declare function runOwnerScopeFilteredSearch<T extends IdentifiedRecord>(opts: OwnerScopeFilteredSearchOptions<T>): Promise<SearchPage<T>>;
53
50
  export declare function runSearch<T>(opts: RunSearchOptions<T>): Promise<SearchPage<T>>;
54
51
  export declare function normalizeSearchPage(value?: number): number;
55
52
  export declare function normalizeSearchPerPage(value?: number): number;
@@ -3,7 +3,11 @@
3
3
  // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
4
  // not use this file except in compliance with the License. You may obtain
5
5
  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
- import { OwnerIDKey, SearchPageKey, SearchPerPageKey, SearchQueryKey, SearchResultResultsKey, } from "@fabriktor/schema";
6
+ import { SearchPageKey, SearchPerPageKey, SearchQueryKey, SearchResultResultsKey, } from "@fabriktor/schema";
7
+ import { normalizeIDs } from "./id.js";
8
+ import { withOwnerQuery } from "./query.js";
9
+ import { mapOwnerScope, mergeRecordsByID } from "./scope.js";
10
+ export { withOwnerQuery } from "./query.js";
7
11
  const firstSearchPage = 1;
8
12
  const defaultSearchPerPage = 30;
9
13
  export async function collectSearchResults(search) {
@@ -21,12 +25,6 @@ export async function collectSearchResults(search) {
21
25
  page = out.next_page;
22
26
  }
23
27
  }
24
- export function withOwnerQuery(owner_id, query) {
25
- return {
26
- ...query,
27
- [OwnerIDKey]: owner_id,
28
- };
29
- }
30
28
  export async function runOwnerSearch(opts) {
31
29
  const { owner_id, query, ...searchOpts } = opts;
32
30
  return await runSearch({
@@ -35,13 +33,13 @@ export async function runOwnerSearch(opts) {
35
33
  });
36
34
  }
37
35
  export async function runOwnerScopeSearch(opts) {
38
- const owner_ids = uniqueOwnerIDs(opts.owner_ids);
36
+ const owner_ids = normalizeIDs(opts.owner_ids);
39
37
  const page = normalizeSearchPage(opts.page);
40
38
  const per_page = normalizeSearchPerPage(opts.per_page);
41
39
  if (owner_ids.length === 0) {
42
40
  return emptySearchPage(page, per_page);
43
41
  }
44
- const pages = await Promise.all(owner_ids.map((owner_id) => runOwnerSearch({
42
+ const pages = await mapOwnerScope(owner_ids, (owner_id) => runOwnerSearch({
45
43
  owner_id,
46
44
  q: opts.q,
47
45
  page,
@@ -49,18 +47,13 @@ export async function runOwnerScopeSearch(opts) {
49
47
  query: opts.query,
50
48
  search: opts.search,
51
49
  signal: opts.signal,
52
- })));
53
- const results = new Map();
54
- for (const ownerPage of pages) {
55
- for (const result of ownerPage.results) {
56
- results.set(result.id, result);
57
- }
58
- }
50
+ }), opts.signal);
51
+ const results = mergeRecordsByID(pages.map((ownerPage) => ownerPage.results));
59
52
  const has_more = pages.some((ownerPage) => ownerPage.has_more);
60
53
  const total_count = sumDefined(pages.map((ownerPage) => ownerPage.total_count));
61
54
  const last_page = maxDefined(pages.map((ownerPage) => ownerPage.last_page));
62
55
  return {
63
- results: [...results.values()],
56
+ results,
64
57
  page,
65
58
  per_page,
66
59
  total_count,
@@ -75,7 +68,7 @@ export async function runOwnerScopeSearch(opts) {
75
68
  // intentionally heavier than runOwnerScopeSearch and should only be used when
76
69
  // the backend model cannot express the requested relationship as a query rule.
77
70
  export async function runOwnerScopeFilteredSearch(opts) {
78
- const owner_ids = uniqueOwnerIDs(opts.owner_ids);
71
+ const owner_ids = normalizeIDs(opts.owner_ids);
79
72
  const page = normalizeSearchPage(opts.page);
80
73
  const per_page = normalizeSearchPerPage(opts.per_page);
81
74
  if (owner_ids.length === 0) {
@@ -86,7 +79,7 @@ export async function runOwnerScopeFilteredSearch(opts) {
86
79
  let source_page = 1;
87
80
  while (done.size < owner_ids.length) {
88
81
  const active_owner_ids = owner_ids.filter((owner_id) => !done.has(owner_id));
89
- const pages = await Promise.all(active_owner_ids.map(async (owner_id) => ({
82
+ const pages = await mapOwnerScope(active_owner_ids, async (owner_id) => ({
90
83
  owner_id,
91
84
  page: await runOwnerSearch({
92
85
  owner_id,
@@ -97,7 +90,7 @@ export async function runOwnerScopeFilteredSearch(opts) {
97
90
  search: opts.search,
98
91
  signal: opts.signal,
99
92
  }),
100
- })));
93
+ }), opts.signal);
101
94
  for (const owner_page of pages) {
102
95
  if (owner_page.page.done) {
103
96
  done.add(owner_page.owner_id);
@@ -208,9 +201,6 @@ function hasMoreSearchResults(opts) {
208
201
  }
209
202
  return opts.result_count >= opts.per_page;
210
203
  }
211
- function uniqueOwnerIDs(ids) {
212
- return [...new Set([...ids].map((id) => id.trim()).filter((id) => id !== ""))];
213
- }
214
204
  function emptySearchPage(page, per_page) {
215
205
  return {
216
206
  results: [],
package/dist/src/fx.js CHANGED
@@ -108,6 +108,8 @@ export class FX {
108
108
  });
109
109
  this.timesheets = newTimesheetsFX({
110
110
  timesheets: opts.timesheets,
111
+ contacts_fx: this.contacts,
112
+ billable: opts.billable,
111
113
  });
112
114
  this.users = newUsersFX({
113
115
  users: opts.users,
@@ -5,14 +5,17 @@ export * from "./chat/chat.js";
5
5
  export * from "./chat/optimistic.js";
6
6
  export * from "./chat/send-queue.js";
7
7
  export * from "./contacts/contacts.js";
8
+ export * from "./contacts/scope.js";
8
9
  export * from "./core/date.js";
9
10
  export * from "./core/err.js";
10
11
  export * from "./core/id.js";
11
12
  export * from "./core/multi-create.js";
12
13
  export * from "./core/pre-action.js";
13
14
  export * from "./core/pull.js";
15
+ export * from "./core/query.js";
14
16
  export * from "./core/queue-store.js";
15
17
  export * from "./core/queue.js";
18
+ export * from "./core/scope.js";
16
19
  export * from "./core/search-controller.js";
17
20
  export * from "./core/search.js";
18
21
  export * from "./feed/feed.js";
@@ -30,7 +33,9 @@ export * from "./processes/processes.js";
30
33
  export * from "./routes/routes.js";
31
34
  export * from "./session/session.js";
32
35
  export * from "./storage/storage.js";
36
+ export * from "./timesheets/access.js";
33
37
  export * from "./timesheets/timesheets.js";
38
+ export * from "./timesheets/workspace.js";
34
39
  export * from "./users/email.js";
35
40
  export * from "./users/phone.js";
36
41
  export * from "./users/username.js";
package/dist/src/index.js CHANGED
@@ -5,14 +5,17 @@ export * from "./chat/chat.js";
5
5
  export * from "./chat/optimistic.js";
6
6
  export * from "./chat/send-queue.js";
7
7
  export * from "./contacts/contacts.js";
8
+ export * from "./contacts/scope.js";
8
9
  export * from "./core/date.js";
9
10
  export * from "./core/err.js";
10
11
  export * from "./core/id.js";
11
12
  export * from "./core/multi-create.js";
12
13
  export * from "./core/pre-action.js";
13
14
  export * from "./core/pull.js";
15
+ export * from "./core/query.js";
14
16
  export * from "./core/queue-store.js";
15
17
  export * from "./core/queue.js";
18
+ export * from "./core/scope.js";
16
19
  export * from "./core/search-controller.js";
17
20
  export * from "./core/search.js";
18
21
  export * from "./feed/feed.js";
@@ -30,7 +33,9 @@ export * from "./processes/processes.js";
30
33
  export * from "./routes/routes.js";
31
34
  export * from "./session/session.js";
32
35
  export * from "./storage/storage.js";
36
+ export * from "./timesheets/access.js";
33
37
  export * from "./timesheets/timesheets.js";
38
+ export * from "./timesheets/workspace.js";
34
39
  export * from "./users/email.js";
35
40
  export * from "./users/phone.js";
36
41
  export * from "./users/username.js";
@@ -7,7 +7,7 @@ import { Cancelled, Completed, Pending, RecurrenceEndTypeAfterDate, RecurrenceEn
7
7
  import { queryParams } from "@fabriktor/client";
8
8
  import { isZeroDate, normalizeInputDate, zeroInputDate, } from "../core/date.js";
9
9
  import { errRequired, errResolveFailed, errValidation, } from "../core/err.js";
10
- import { requiredOperationID, resolvedID } from "../core/id.js";
10
+ import { normalizeIDs, requiredOperationID, resolvedID } from "../core/id.js";
11
11
  import { runPreActionCheck } from "../core/pre-action.js";
12
12
  import { runOwnerSearch, runSearch, withOwnerQuery, } from "../core/search.js";
13
13
  const msgJobIDRequired = "Job ID is required.";
@@ -664,19 +664,6 @@ function requiredID(v, field, msg) {
664
664
  }
665
665
  return out;
666
666
  }
667
- function normalizeIDs(ids) {
668
- const seen = new Set();
669
- const out = [];
670
- for (const raw of ids) {
671
- const id = resolvedID(raw);
672
- if (id === undefined || seen.has(id)) {
673
- continue;
674
- }
675
- seen.add(id);
676
- out.push(id);
677
- }
678
- return out;
679
- }
680
667
  function removedIDs(before, after) {
681
668
  const current = new Set(normalizeIDs(after));
682
669
  return normalizeIDs(before).filter((id) => !current.has(id));
@@ -0,0 +1,27 @@
1
+ import { type Summary, type WeekSheet } from "@fabriktor/schema";
2
+ import type { EmploymentOwnerScopeOptions } from "../contacts/contacts.js";
3
+ import { type EmploymentActor, type EmploymentReadContext } from "../contacts/scope.js";
4
+ import { type TimesheetFilters } from "./workspace.js";
5
+ export type LoadTimesheetsReadContextOptions = EmploymentOwnerScopeOptions;
6
+ export type TimesheetsReadContext = EmploymentReadContext;
7
+ export type TimesheetsActor = EmploymentActor;
8
+ export type TimesheetsReadOptions = TimesheetFilters & {
9
+ context: TimesheetsReadContext;
10
+ };
11
+ export type QueryTimesheetSyncDestinationsOptions = {
12
+ context: TimesheetsReadContext;
13
+ source: WeekSheet;
14
+ signal?: AbortSignal;
15
+ };
16
+ type TimesheetRecord = WeekSheet | Summary;
17
+ export declare function timesheetCanReadResource(context: TimesheetsActor): boolean;
18
+ export declare function timesheetCanCreateWeek(context: TimesheetsActor): boolean;
19
+ export declare function timesheetCanReadRecord(context: TimesheetsActor, record: TimesheetRecord | null | undefined): boolean;
20
+ export declare function timesheetCanWriteRecord(context: TimesheetsActor, record: TimesheetRecord | null | undefined): boolean;
21
+ export declare function timesheetCanDeleteRecord(context: TimesheetsActor, record: TimesheetRecord | null | undefined): boolean;
22
+ export declare function timesheetCanApproveRecord(context: TimesheetsActor, record: WeekSheet | null | undefined): boolean;
23
+ export declare function timesheetCanEditDay(context: TimesheetsActor, record: WeekSheet | null | undefined, week_day: number): boolean;
24
+ export declare function timesheetCanSyncDestination(context: TimesheetsActor, source: WeekSheet, destination: WeekSheet): boolean;
25
+ export declare function timesheetEmployeeScopeIDs(context: TimesheetsReadContext): string[];
26
+ export declare function timesheetReadFilters(opts: TimesheetsReadOptions): TimesheetFilters;
27
+ export {};
@@ -0,0 +1,48 @@
1
+ import { PunchStatusWorking, UserTypeEmployee, } from "@fabriktor/schema";
2
+ import { employmentActivityOwnerIDs, employmentCanRead, employmentEmployeeIDs, employmentEmployerIDs, employmentManagesRecord, employmentOwnsRecord, } from "../contacts/scope.js";
3
+ import { restrictOwnerIDs } from "../core/scope.js";
4
+ import { timesheetCanApprove, timesheetCanSync, timesheetHasWorkingPunches, timesheetIsMutable, } from "./workspace.js";
5
+ export function timesheetCanReadResource(context) {
6
+ return employmentCanRead(context);
7
+ }
8
+ export function timesheetCanCreateWeek(context) {
9
+ return timesheetCanReadResource(context) && context.active_mode === UserTypeEmployee;
10
+ }
11
+ export function timesheetCanReadRecord(context, record) {
12
+ return timesheetCanReadResource(context) && record != null && (employmentOwnsRecord(context, record) || employmentManagesRecord(context, record));
13
+ }
14
+ export function timesheetCanWriteRecord(context, record) {
15
+ return record != null && timesheetCanReadRecord(context, record) && (!("sheets" in record) || timesheetIsMutable(record));
16
+ }
17
+ export function timesheetCanDeleteRecord(context, record) {
18
+ return record != null && timesheetCanWriteRecord(context, record) && (!("sheets" in record) || !timesheetHasWorkingPunches(record));
19
+ }
20
+ export function timesheetCanApproveRecord(context, record) {
21
+ return record != null && timesheetCanReadResource(context) &&
22
+ employmentManagesRecord(context, record) && timesheetCanApprove(record);
23
+ }
24
+ export function timesheetCanEditDay(context, record, week_day) {
25
+ if (record == null || !timesheetCanCreateWeek(context) ||
26
+ !employmentOwnsRecord(context, record) || !timesheetIsMutable(record))
27
+ return false;
28
+ const day = record.sheets.find((value) => value.week_day === week_day);
29
+ return day !== undefined && day.punches.length > 0 &&
30
+ day.punches.every((punch) => punch.punch_status !== PunchStatusWorking);
31
+ }
32
+ export function timesheetCanSyncDestination(context, source, destination) {
33
+ return timesheetCanApproveRecord(context, source) &&
34
+ timesheetCanWriteRecord(context, destination) && timesheetCanSync(source, destination);
35
+ }
36
+ export function timesheetEmployeeScopeIDs(context) {
37
+ return employmentEmployeeIDs(context);
38
+ }
39
+ export function timesheetReadFilters(opts) {
40
+ const { context, ...filters } = opts;
41
+ if (!timesheetCanReadResource(context))
42
+ return { ...filters, owner_ids: [] };
43
+ return {
44
+ ...filters,
45
+ owner_ids: restrictOwnerIDs(employmentActivityOwnerIDs(context), opts.owner_ids),
46
+ employer_ids: employmentEmployerIDs(context, opts.employer_ids),
47
+ };
48
+ }
@@ -1,5 +1,8 @@
1
1
  import { type Date as SchemaDate, type OperationResult, type Summary, type WeekSheet } from "@fabriktor/schema";
2
- import type { TimesheetsOperator } from "@fabriktor/client";
2
+ import type { BillableOperator, TimesheetsOperator } from "@fabriktor/client";
3
+ import type { ContactsFXOperator } from "../contacts/contacts.js";
4
+ import { type LoadTimesheetsReadContextOptions, type QueryTimesheetSyncDestinationsOptions, type TimesheetsReadContext, type TimesheetsReadOptions } from "./access.js";
5
+ import { TimesheetsWorkspace } from "./workspace.js";
3
6
  type PunchPayload = Parameters<TimesheetsOperator["punch"]>[0];
4
7
  type WeekSheetReplaceOptions = Parameters<TimesheetsOperator["replaceOneWeekSheet"]>[0];
5
8
  type SummaryReplaceOptions = Parameters<TimesheetsOperator["replaceOneSummary"]>[0];
@@ -7,6 +10,9 @@ export type ReplaceWeekSheetOptions = WeekSheetReplaceOptions;
7
10
  export type ReplaceSummaryOptions = SummaryReplaceOptions;
8
11
  export type TimesheetsFXOptions = {
9
12
  timesheets: TimesheetsOperator;
13
+ contacts_fx: ContactsFXOperator;
14
+ billable?: BillableOperator;
15
+ now?: () => globalThis.Date;
10
16
  };
11
17
  export type PunchContext = Pick<PunchPayload, "timezone" | "location_id" | "employer_id">;
12
18
  export type PunchInOptions = PunchContext & {
@@ -61,6 +67,11 @@ export type ApproveWeekSheetsOptions = {
61
67
  week_sheets: WeekSheet[];
62
68
  };
63
69
  export interface TimesheetsFXOperator {
70
+ readonly workspace: TimesheetsWorkspace;
71
+ loadReadContext(opts: LoadTimesheetsReadContextOptions): Promise<TimesheetsReadContext>;
72
+ queryReadableWeekSheets(opts: TimesheetsReadOptions): Promise<WeekSheet[]>;
73
+ queryReadableSummaries(opts: TimesheetsReadOptions): Promise<Summary[]>;
74
+ querySyncDestinations(opts: QueryTimesheetSyncDestinationsOptions): Promise<WeekSheet[]>;
64
75
  replaceWeekSheet(opts: ReplaceWeekSheetOptions): Promise<WeekSheet>;
65
76
  replaceSummary(opts: ReplaceSummaryOptions): Promise<Summary>;
66
77
  punchControls(opts: PunchControlsOptions): PunchControls;
@@ -76,9 +87,15 @@ export interface TimesheetsFXOperator {
76
87
  approveWeekSheets(opts: ApproveWeekSheetsOptions): Promise<void>;
77
88
  }
78
89
  export declare class TimesheetsFX implements TimesheetsFXOperator {
90
+ readonly workspace: TimesheetsWorkspace;
79
91
  private timesheets;
92
+ private contacts_fx;
80
93
  private punch_in_progress;
81
94
  constructor(opts: TimesheetsFXOptions);
95
+ loadReadContext(opts: LoadTimesheetsReadContextOptions): Promise<TimesheetsReadContext>;
96
+ queryReadableWeekSheets(opts: TimesheetsReadOptions): Promise<WeekSheet[]>;
97
+ queryReadableSummaries(opts: TimesheetsReadOptions): Promise<Summary[]>;
98
+ querySyncDestinations(opts: QueryTimesheetSyncDestinationsOptions): Promise<WeekSheet[]>;
82
99
  replaceWeekSheet(opts: ReplaceWeekSheetOptions): Promise<WeekSheet>;
83
100
  replaceSummary(opts: ReplaceSummaryOptions): Promise<Summary>;
84
101
  punchControls(opts: PunchControlsOptions): PunchControls;
@@ -6,6 +6,11 @@
6
6
  import { PunchModeDefault, PunchModeDelete, PunchModeReplace, PunchModeWeekSheet, PunchStatusPending, PunchStatusWorking, PunchTypeIn, PunchTypeOut, PunchTypeRideIn, PunchTypeRideOut, ZeroID, } from "@fabriktor/schema";
7
7
  import { zeroDate } from "../core/date.js";
8
8
  import { errRequired, errResolveFailed, errValidation, } from "../core/err.js";
9
+ import { normalizeIDs } from "../core/id.js";
10
+ import { loadEmploymentReadContext } from "../contacts/scope.js";
11
+ import { PreActionBlocked } from "../core/pre-action.js";
12
+ import { timesheetCanApproveRecord, timesheetCanSyncDestination, timesheetReadFilters, } from "./access.js";
13
+ import { TimesheetsWorkspace, TimesheetsBlock, timesheetCanApprove, timesheetCanSync, timesheetIsMutable, } from "./workspace.js";
9
14
  const allWeekDays = [0, 1, 2, 3, 4, 5, 6];
10
15
  const minuteStep = 15;
11
16
  const msgPunchInProgress = "A punch action is already in progress.";
@@ -27,11 +32,39 @@ const resourceSummary = "summary";
27
32
  const msgWeekSheetMissing = "Week sheet could not be resolved after the mutation.";
28
33
  const msgSummaryMissing = "Summary could not be resolved after the mutation.";
29
34
  export class TimesheetsFX {
35
+ workspace;
30
36
  timesheets;
37
+ contacts_fx;
31
38
  punch_in_progress;
32
39
  constructor(opts) {
33
40
  this.timesheets = opts.timesheets;
41
+ this.contacts_fx = opts.contacts_fx;
34
42
  this.punch_in_progress = false;
43
+ this.workspace = new TimesheetsWorkspace(opts.timesheets, this, opts.billable, opts.now);
44
+ }
45
+ async loadReadContext(opts) {
46
+ return await loadEmploymentReadContext(this.contacts_fx, opts);
47
+ }
48
+ async queryReadableWeekSheets(opts) {
49
+ return await this.workspace.weeks(timesheetReadFilters(opts));
50
+ }
51
+ async queryReadableSummaries(opts) {
52
+ return await this.workspace.summaries(timesheetReadFilters(opts));
53
+ }
54
+ async querySyncDestinations(opts) {
55
+ const source = await this.workspace.week(opts.source.id, opts.signal);
56
+ if (source.updated_at !== opts.source.updated_at)
57
+ throw new PreActionBlocked(TimesheetsBlock.Changed);
58
+ if (!timesheetCanApproveRecord(opts.context, source))
59
+ throw new PreActionBlocked(TimesheetsBlock.InvalidSync);
60
+ const weeks = await this.queryReadableWeekSheets({
61
+ context: opts.context,
62
+ employer_ids: [source.employer_id],
63
+ period_indexes: [source.period_index],
64
+ is_archived: false,
65
+ signal: opts.signal,
66
+ });
67
+ return weeks.filter((week) => timesheetCanSyncDestination(opts.context, source, week));
35
68
  }
36
69
  async replaceWeekSheet(opts) {
37
70
  await this.timesheets.replaceOneWeekSheet(opts);
@@ -43,7 +76,7 @@ export class TimesheetsFX {
43
76
  }
44
77
  punchControls(opts) {
45
78
  validateWeekDay(opts.week_day, "week_day");
46
- if (opts.week_sheet?.is_archived) {
79
+ if (opts.week_sheet && !timesheetIsMutable(opts.week_sheet)) {
47
80
  return disabledPunchControls();
48
81
  }
49
82
  const day = opts.week_sheet?.sheets.find((sheet) => sheet.week_day === opts.week_day);
@@ -207,7 +240,13 @@ export class TimesheetsFX {
207
240
  });
208
241
  }
209
242
  const ids = [];
210
- for (const week_sheet of opts.week_sheets) {
243
+ for (const expected of opts.week_sheets) {
244
+ // Query cleaners can return partial days. Always authorize the whole fresh record.
245
+ const week_sheet = await this.findWeekSheet(expected.id);
246
+ if (week_sheet.updated_at !== expected.updated_at)
247
+ throw new PreActionBlocked(TimesheetsBlock.Changed);
248
+ if (!timesheetCanApprove(week_sheet))
249
+ throw new PreActionBlocked(TimesheetsBlock.Locked);
211
250
  const id = requiredID(week_sheet.id, "week_sheets", msgWeekSheetIDRequired);
212
251
  if (week_sheet.employer_id !== employer_id) {
213
252
  throw errValidation({
@@ -266,6 +305,12 @@ export class TimesheetsFX {
266
305
  return summary;
267
306
  }
268
307
  async syncSheets(opts) {
308
+ const source = await this.findWeekSheet(opts.from.id);
309
+ if (source.updated_at !== opts.from.updated_at)
310
+ throw new PreActionBlocked(TimesheetsBlock.Changed);
311
+ if (!timesheetCanApprove(source))
312
+ throw new PreActionBlocked(TimesheetsBlock.InvalidSync);
313
+ opts = { ...opts, from: source };
269
314
  const from_id = requiredID(opts.from.id, "from", msgWeekSheetIDRequired);
270
315
  if (opts.to.length === 0) {
271
316
  throw errRequired({
@@ -274,20 +319,19 @@ export class TimesheetsFX {
274
319
  });
275
320
  }
276
321
  const to_ids = [];
277
- for (const week_sheet of opts.to) {
322
+ for (const expected of opts.to) {
323
+ const week_sheet = await this.findWeekSheet(expected.id);
324
+ if (week_sheet.updated_at !== expected.updated_at)
325
+ throw new PreActionBlocked(TimesheetsBlock.Changed);
278
326
  const id = requiredID(week_sheet.id, "to", msgWeekSheetIDRequired);
279
- if (week_sheet.is_archived) {
280
- throw errValidation({
281
- field: "to",
282
- msg: msgWeekSheetArchived,
283
- });
284
- }
285
327
  if (week_sheet.employer_id !== opts.from.employer_id) {
286
328
  throw errValidation({
287
329
  field: "to",
288
330
  msg: msgSyncEmployerMismatch,
289
331
  });
290
332
  }
333
+ if (!timesheetCanSync(opts.from, week_sheet))
334
+ throw new PreActionBlocked(TimesheetsBlock.InvalidSync);
291
335
  to_ids.push(id);
292
336
  }
293
337
  const input = {
@@ -420,16 +464,3 @@ function requiredText(v, field, msg) {
420
464
  }
421
465
  return out;
422
466
  }
423
- function normalizeIDs(ids) {
424
- const seen = new Set();
425
- const out = [];
426
- for (const raw of ids) {
427
- const id = raw.trim();
428
- if (id === "" || id === ZeroID || seen.has(id)) {
429
- continue;
430
- }
431
- seen.add(id);
432
- out.push(id);
433
- }
434
- return out;
435
- }
@@ -0,0 +1,79 @@
1
+ import { type BillableOperator, type TimesheetsOperator } from "@fabriktor/client";
2
+ import { PunchTypeIn, PunchTypeOut, PunchTypeRideIn, PunchTypeRideOut, type InDate, type Punch, type Summary, type WeekSheet } from "@fabriktor/schema";
3
+ import type { TimesheetsFXOperator } from "./timesheets.js";
4
+ export type TimesheetFilters = {
5
+ owner_ids?: WeekSheet["owner_id"][];
6
+ employer_ids?: WeekSheet["employer_id"][];
7
+ period_indexes?: WeekSheet["period_index"][];
8
+ location_ids?: Punch["location_id"][];
9
+ is_archived?: WeekSheet["is_archived"];
10
+ punch_status?: Punch["punch_status"];
11
+ signal?: AbortSignal;
12
+ };
13
+ export type PunchStateOptions = {
14
+ owner_id: string;
15
+ employer_id: string;
16
+ timezone: string;
17
+ date?: InDate;
18
+ };
19
+ export type TimesheetPunchState = {
20
+ week_sheet?: WeekSheet;
21
+ punch?: Punch;
22
+ week_day: number;
23
+ stale_day: boolean;
24
+ };
25
+ export type TimesheetPunchAction = typeof PunchTypeIn | typeof PunchTypeOut | typeof PunchTypeRideIn | typeof PunchTypeRideOut;
26
+ export type CheckedPunchOptions = PunchStateOptions & {
27
+ location_id: string;
28
+ action: TimesheetPunchAction;
29
+ expected: TimesheetPunchState;
30
+ total_pause_minutes?: number;
31
+ total_meal_minutes?: number;
32
+ };
33
+ export type SaveTimesheetDayOptions = PunchStateOptions & {
34
+ location_id: string;
35
+ start: InDate;
36
+ end: InDate;
37
+ total_pause_minutes: number;
38
+ total_meal_minutes: number;
39
+ replace?: WeekSheet;
40
+ };
41
+ export declare const TimesheetsBlock: {
42
+ readonly Changed: "timesheets_changed";
43
+ readonly Locked: "timesheets_locked";
44
+ readonly Active: "timesheets_active";
45
+ readonly InvalidDay: "timesheets_invalid_day";
46
+ readonly InvalidSync: "timesheets_invalid_sync";
47
+ readonly Unconfirmed: "timesheets_unconfirmed";
48
+ };
49
+ export declare function timesheetPunches(sheet: WeekSheet): Punch[];
50
+ export declare function timesheetHasWorkingPunches(sheet: WeekSheet): boolean;
51
+ export declare function timesheetIsMutable(sheet: WeekSheet): boolean;
52
+ export declare function timesheetCanApprove(sheet: WeekSheet): boolean;
53
+ export declare function timesheetCanSync(source: WeekSheet, destination: WeekSheet): boolean;
54
+ export declare function timesheetStateKey(state: TimesheetPunchState): string;
55
+ export declare function timesheetWeekDay(date: InDate): number;
56
+ export declare class TimesheetsWorkspace {
57
+ private readonly client;
58
+ private readonly actions;
59
+ private readonly billable?;
60
+ private readonly now;
61
+ constructor(client: TimesheetsOperator, actions: TimesheetsFXOperator, billable?: BillableOperator | undefined, now?: () => Date);
62
+ weeks(opts: TimesheetFilters): Promise<WeekSheet[]>;
63
+ summaries(opts: TimesheetFilters): Promise<Summary[]>;
64
+ week(id: string, signal?: AbortSignal): Promise<WeekSheet>;
65
+ summary(id: string, signal?: AbortSignal): Promise<Summary>;
66
+ periods(employer_id: string, start: InDate, end?: InDate): Promise<number[]>;
67
+ punchState(opts: PunchStateOptions): Promise<TimesheetPunchState>;
68
+ punch(opts: CheckedPunchOptions): Promise<WeekSheet>;
69
+ saveDay(opts: SaveTimesheetDayOptions): Promise<WeekSheet>;
70
+ removeDay(owner_id: string, expected: WeekSheet, date: InDate): Promise<void>;
71
+ removeWeek(actor_id: string, expected: WeekSheet): Promise<void>;
72
+ blankWeek(opts: PunchStateOptions & {
73
+ location_id: string;
74
+ date: InDate;
75
+ }): Promise<WeekSheet>;
76
+ private queryOwners;
77
+ private checkedWeek;
78
+ private validateDay;
79
+ }
@@ -0,0 +1,331 @@
1
+ import { mergeQueryOptions, queryNestedParam, queryParams, } from "@fabriktor/client";
2
+ import { PunchStatusApproved, PunchStatusPending, PunchStatusWorking, PunchTypeIn, PunchTypeOut, PunchTypeRideIn, PunchTypeRideOut, } from "@fabriktor/schema";
3
+ import { inDateAtTimestamp, zeroDate } from "../core/date.js";
4
+ import { errRequired } from "../core/err.js";
5
+ import { PreActionBlocked } from "../core/pre-action.js";
6
+ import { queryRecordParams, runOwnerScopeQuery, } from "../core/query.js";
7
+ export const TimesheetsBlock = {
8
+ Changed: "timesheets_changed",
9
+ Locked: "timesheets_locked",
10
+ Active: "timesheets_active",
11
+ InvalidDay: "timesheets_invalid_day",
12
+ InvalidSync: "timesheets_invalid_sync",
13
+ Unconfirmed: "timesheets_unconfirmed",
14
+ };
15
+ function requireState(allowed, reason) {
16
+ if (!allowed)
17
+ throw new PreActionBlocked(reason);
18
+ }
19
+ export function timesheetPunches(sheet) {
20
+ return sheet.sheets.flatMap((day) => day.punches);
21
+ }
22
+ export function timesheetHasWorkingPunches(sheet) {
23
+ return timesheetPunches(sheet).some((punch) => punch.punch_status === PunchStatusWorking);
24
+ }
25
+ export function timesheetIsMutable(sheet) {
26
+ return (!sheet.is_archived &&
27
+ !timesheetPunches(sheet).some((p) => p.punch_status === PunchStatusApproved));
28
+ }
29
+ export function timesheetCanApprove(sheet) {
30
+ const punches = timesheetPunches(sheet);
31
+ return (timesheetIsMutable(sheet) &&
32
+ punches.length > 0 &&
33
+ punches.every((p) => p.punch_status === PunchStatusPending));
34
+ }
35
+ export function timesheetCanSync(source, destination) {
36
+ return timesheetCanApprove(source) && timesheetIsMutable(destination) &&
37
+ !timesheetHasWorkingPunches(destination) &&
38
+ destination.employer_id === source.employer_id &&
39
+ destination.id !== source.id &&
40
+ destination.period_index === source.period_index &&
41
+ destination.timezone === source.timezone;
42
+ }
43
+ export function timesheetStateKey(state) {
44
+ return JSON.stringify([
45
+ state.week_sheet?.id,
46
+ state.week_sheet?.updated_at,
47
+ state.week_sheet?.is_archived,
48
+ state.week_day,
49
+ state.punch,
50
+ ]);
51
+ }
52
+ export function timesheetWeekDay(date) {
53
+ // Calendar arithmetic on explicit date parts; never the device timezone.
54
+ return new Date(Date.UTC(date.y, date.m - 1, date.d)).getUTCDay();
55
+ }
56
+ function sameDay(a, b) {
57
+ return a.y === b.y && a.m === b.m && a.d === b.d;
58
+ }
59
+ function wallTime(date) {
60
+ return Date.UTC(date.y, date.m - 1, date.d, date.h, date.min);
61
+ }
62
+ function schemaDate(date) {
63
+ // The backend computes UTC from the supplied wall-clock fields and tz.
64
+ return { ...zeroDate(), ...date };
65
+ }
66
+ export class TimesheetsWorkspace {
67
+ client;
68
+ actions;
69
+ billable;
70
+ now;
71
+ constructor(client, actions, billable, now = () => new Date()) {
72
+ this.client = client;
73
+ this.actions = actions;
74
+ this.billable = billable;
75
+ this.now = now;
76
+ }
77
+ async weeks(opts) {
78
+ return await this.queryOwners(opts, mergeQueryOptions(query(opts), queryParams({ is_archived: opts.is_archived }), queryRecordParams("sheets", mergeQueryOptions(queryNestedParam(["punches", "location_id"], opts.location_ids), queryNestedParam(["punches", "punch_status"], opts.punch_status)))), (queryOpts) => this.client.queryWeekSheets(queryOpts));
79
+ }
80
+ async summaries(opts) {
81
+ return await this.queryOwners(opts, mergeQueryOptions(query(opts), queryNestedParam(["location_records", "location_id"], opts.location_ids)), (queryOpts) => this.client.querySummaries(queryOpts));
82
+ }
83
+ async week(id, signal) {
84
+ const { value } = await this.client.findOneWeekSheet({ id, signal });
85
+ requireState(!!value, TimesheetsBlock.Changed);
86
+ return value;
87
+ }
88
+ async summary(id, signal) {
89
+ const { value } = await this.client.findOneSummary({ id, signal });
90
+ requireState(!!value, TimesheetsBlock.Changed);
91
+ return value;
92
+ }
93
+ async periods(employer_id, start, end = start) {
94
+ requireState(!!this.billable && !!employer_id, TimesheetsBlock.InvalidDay);
95
+ const result = await this.billable.periodsFromDates({
96
+ employer_id,
97
+ start,
98
+ end,
99
+ });
100
+ requireState(!!result.value?.ints.length, TimesheetsBlock.InvalidDay);
101
+ return result.value.ints;
102
+ }
103
+ async punchState(opts) {
104
+ requireState(!!opts.owner_id && !!opts.timezone, TimesheetsBlock.InvalidDay);
105
+ const today = opts.date ?? inDateAtTimestamp(this.now().toISOString(), opts.timezone);
106
+ const week_day = timesheetWeekDay(today);
107
+ // Check every open work punch, including other employers/locations/weeks.
108
+ // A scope selection must never hide an already open shift.
109
+ const active = await this.weeks({
110
+ owner_ids: [opts.owner_id],
111
+ is_archived: false,
112
+ punch_status: PunchStatusWorking,
113
+ });
114
+ const open = active.flatMap((week_sheet) => week_sheet.sheets.flatMap((day) => day.punches
115
+ .filter((p) => p.punch_status === PunchStatusWorking)
116
+ .map((punch) => ({ week_sheet, punch, week_day: day.week_day }))));
117
+ requireState(open.length <= 1, TimesheetsBlock.Active);
118
+ if (open[0]) {
119
+ const current = open[0];
120
+ const date = inDateAtTimestamp(this.now().toISOString(), current.punch.timezone);
121
+ return {
122
+ ...current,
123
+ stale_day: !sameDay(current.punch.punch_in_time, opts.date ?? date),
124
+ };
125
+ }
126
+ if (!opts.employer_id)
127
+ return { week_day, stale_day: false };
128
+ const indexes = await this.periods(opts.employer_id, today);
129
+ const weeks = await this.weeks({
130
+ owner_ids: [opts.owner_id],
131
+ employer_ids: [opts.employer_id],
132
+ period_indexes: indexes,
133
+ });
134
+ requireState(weeks.length <= 1, TimesheetsBlock.Changed);
135
+ const week_sheet = weeks[0];
136
+ const punch = week_sheet?.sheets
137
+ .find((day) => day.week_day === week_day)
138
+ ?.punches.at(-1);
139
+ return { week_sheet, punch, week_day, stale_day: false };
140
+ }
141
+ async punch(opts) {
142
+ if (opts.date)
143
+ this.validateDay(opts.date, opts.timezone);
144
+ const current = await this.punchState(opts);
145
+ requireState(timesheetStateKey(current) === timesheetStateKey(opts.expected), TimesheetsBlock.Changed);
146
+ requireState(!current.stale_day, TimesheetsBlock.InvalidDay);
147
+ requireState(!current.week_sheet || timesheetIsMutable(current.week_sheet), TimesheetsBlock.Locked);
148
+ const controls = this.actions.punchControls(current);
149
+ const allowed = {
150
+ [PunchTypeIn]: controls.can_punch_in,
151
+ [PunchTypeOut]: controls.can_punch_out,
152
+ [PunchTypeRideIn]: controls.can_ride_in,
153
+ [PunchTypeRideOut]: controls.can_ride_out,
154
+ };
155
+ requireState(allowed[opts.action], TimesheetsBlock.Changed);
156
+ if (opts.action !== PunchTypeIn) {
157
+ requireState(current.punch?.location_id === opts.location_id &&
158
+ current.punch.employer_id === opts.employer_id &&
159
+ current.punch.timezone === opts.timezone, TimesheetsBlock.Changed);
160
+ }
161
+ if (opts.date && current.punch && opts.action !== PunchTypeIn) {
162
+ requireState(sameDay(opts.date, current.punch.punch_in_time) &&
163
+ wallTime(opts.date) >= wallTime(current.punch.punch_in_time), TimesheetsBlock.InvalidDay);
164
+ }
165
+ const context = {
166
+ employer_id: opts.employer_id,
167
+ location_id: opts.location_id,
168
+ timezone: opts.timezone,
169
+ explicit_date: opts.date ? schemaDate(opts.date) : undefined,
170
+ };
171
+ const result = opts.action === PunchTypeIn
172
+ ? await this.actions.punchIn(context)
173
+ : opts.action === PunchTypeOut
174
+ ? await this.actions.punchOut({
175
+ ...context,
176
+ total_pause_minutes: opts.total_pause_minutes ?? 0,
177
+ total_meal_minutes: opts.total_meal_minutes ?? 0,
178
+ })
179
+ : opts.action === PunchTypeRideIn
180
+ ? await this.actions.rideIn(context)
181
+ : await this.actions.rideOut(context);
182
+ requireState(!!result.id, TimesheetsBlock.Unconfirmed);
183
+ const sheet = await this.week(result.id);
184
+ const last = sheet.sheets
185
+ .find((day) => day.week_day === current.week_day)
186
+ ?.punches.at(-1);
187
+ requireState(!!last &&
188
+ last.location_id === opts.location_id &&
189
+ !sheet.is_archived &&
190
+ (opts.action === PunchTypeOut
191
+ ? last.punch_status === PunchStatusPending
192
+ : last.punch_status === PunchStatusWorking) &&
193
+ (opts.action !== PunchTypeRideIn ||
194
+ last.rides.at(-1)?.is_active === true) &&
195
+ (opts.action !== PunchTypeRideOut ||
196
+ last.rides.at(-1)?.is_active === false), TimesheetsBlock.Unconfirmed);
197
+ return sheet;
198
+ }
199
+ async saveDay(opts) {
200
+ this.validateDay(opts.start, opts.timezone);
201
+ this.validateDay(opts.end, opts.timezone);
202
+ requireState(sameDay(opts.start, opts.end) &&
203
+ wallTime(opts.end) > wallTime(opts.start), TimesheetsBlock.InvalidDay);
204
+ const indexes = await this.periods(opts.employer_id, opts.start);
205
+ const matches = await this.weeks({
206
+ owner_ids: [opts.owner_id],
207
+ employer_ids: [opts.employer_id],
208
+ period_indexes: indexes,
209
+ });
210
+ requireState(matches.length <= 1, TimesheetsBlock.Changed);
211
+ const sheet = matches[0];
212
+ const day = sheet?.sheets.find((d) => d.week_day === timesheetWeekDay(opts.start));
213
+ if (opts.replace) {
214
+ requireState(sheet?.id === opts.replace.id &&
215
+ sheet.updated_at === opts.replace.updated_at, TimesheetsBlock.Changed);
216
+ }
217
+ else {
218
+ // A new manual entry must not silently replace an existing day.
219
+ requireState(!day?.punches.length, TimesheetsBlock.Changed);
220
+ }
221
+ requireState(!sheet || timesheetIsMutable(sheet), TimesheetsBlock.Locked);
222
+ requireState(!day?.punches.some((p) => p.punch_status === PunchStatusWorking), TimesheetsBlock.Active);
223
+ const open = await this.weeks({
224
+ owner_ids: [opts.owner_id],
225
+ is_archived: false,
226
+ punch_status: PunchStatusWorking,
227
+ });
228
+ requireState(open.length === 0, TimesheetsBlock.Active);
229
+ const result = await this.actions.replaceDay({
230
+ employer_id: opts.employer_id,
231
+ location_id: opts.location_id,
232
+ timezone: opts.timezone,
233
+ punch_in_date: schemaDate(opts.start),
234
+ punch_out_date: schemaDate(opts.end),
235
+ total_pause_minutes: opts.total_pause_minutes,
236
+ total_meal_minutes: opts.total_meal_minutes,
237
+ });
238
+ requireState(!!result.punch_out.id, TimesheetsBlock.Unconfirmed);
239
+ return this.week(result.punch_out.id);
240
+ }
241
+ async removeDay(owner_id, expected, date) {
242
+ const sheet = await this.checkedWeek(expected);
243
+ requireState(sheet.owner_id === owner_id, TimesheetsBlock.Locked);
244
+ const day = sheet.sheets.find((d) => d.week_day === timesheetWeekDay(date));
245
+ requireState(!!day?.punches.length &&
246
+ !day.punches.some((p) => p.punch_status === PunchStatusWorking), TimesheetsBlock.Active);
247
+ const punch = day.punches[0];
248
+ requireState(sameDay(date, punch.punch_in_time), TimesheetsBlock.InvalidDay);
249
+ await this.actions.deleteDay({
250
+ employer_id: sheet.employer_id,
251
+ location_id: punch.location_id,
252
+ timezone: punch.timezone,
253
+ explicit_date: schemaDate(date),
254
+ });
255
+ }
256
+ async removeWeek(actor_id, expected) {
257
+ const sheet = await this.checkedWeek(expected);
258
+ requireState(sheet.owner_id === actor_id || sheet.employer_id === actor_id, TimesheetsBlock.Locked);
259
+ requireState(!timesheetHasWorkingPunches(sheet), TimesheetsBlock.Active);
260
+ await this.client.deleteOneWeekSheet({ id: sheet.id });
261
+ }
262
+ async blankWeek(opts) {
263
+ this.validateDay(opts.date, opts.timezone);
264
+ const indexes = await this.periods(opts.employer_id, opts.date);
265
+ const matches = await this.weeks({
266
+ owner_ids: [opts.owner_id],
267
+ employer_ids: [opts.employer_id],
268
+ period_indexes: indexes,
269
+ });
270
+ requireState(matches.length === 0, TimesheetsBlock.Changed);
271
+ const result = await this.actions.createBlankWeekSheet({
272
+ ...opts,
273
+ explicit_date: schemaDate(opts.date),
274
+ });
275
+ requireState(!!result.id, TimesheetsBlock.Unconfirmed);
276
+ return this.week(result.id);
277
+ }
278
+ async queryOwners(opts, query, fetch) {
279
+ if (emptyScope(opts))
280
+ return [];
281
+ if (opts.owner_ids === undefined) {
282
+ throw errRequired({
283
+ field: "owner_ids",
284
+ msg: "Owner IDs are required for a timesheet query.",
285
+ });
286
+ }
287
+ return await runOwnerScopeQuery({
288
+ owner_ids: opts.owner_ids,
289
+ query,
290
+ signal: opts.signal,
291
+ fetch,
292
+ });
293
+ }
294
+ async checkedWeek(expected) {
295
+ const sheet = await this.week(expected.id);
296
+ requireState(sheet.updated_at === expected.updated_at, TimesheetsBlock.Changed);
297
+ requireState(timesheetIsMutable(sheet), TimesheetsBlock.Locked);
298
+ return sheet;
299
+ }
300
+ validateDay(date, timezone) {
301
+ const now = inDateAtTimestamp(this.now().toISOString(), timezone);
302
+ const calendar = new Date(Date.UTC(date.y, date.m - 1, date.d));
303
+ requireState(!!now &&
304
+ date.tz === timezone &&
305
+ date.y > 1 &&
306
+ date.m >= 1 &&
307
+ date.m <= 12 &&
308
+ calendar.getUTCDate() === date.d &&
309
+ Number.isInteger(date.h) &&
310
+ date.h >= 0 &&
311
+ date.h < 24 &&
312
+ Number.isInteger(date.min) &&
313
+ date.min >= 0 &&
314
+ date.min < 60 &&
315
+ wallTime(date) <= wallTime(now), TimesheetsBlock.InvalidDay);
316
+ }
317
+ }
318
+ function emptyScope(opts) {
319
+ return [
320
+ opts.owner_ids,
321
+ opts.employer_ids,
322
+ opts.period_indexes,
323
+ opts.location_ids,
324
+ ].some((ids) => ids?.length === 0);
325
+ }
326
+ function query(opts) {
327
+ return queryParams({
328
+ employer_id: opts.employer_ids,
329
+ period_index: opts.period_indexes,
330
+ });
331
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.114",
3
+ "version": "0.0.116",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typescript": "^6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@fabriktor/client": "0.0.86",
20
+ "@fabriktor/client": "0.0.87",
21
21
  "@fabriktor/fx": "0.0.94",
22
22
  "@fabriktor/schema": "0.0.63"
23
23
  }