@fabriktor/fx 0.0.148 → 0.0.150
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/dist/src/chat/data.js +10 -5
- package/dist/src/chat/forms.d.ts +7 -0
- package/dist/src/chat/forms.js +24 -0
- package/dist/src/chat/send-queue.d.ts +2 -0
- package/dist/src/chat/send-queue.js +25 -5
- package/dist/src/core/events.d.ts +2 -0
- package/dist/src/core/events.js +20 -1
- package/dist/src/core/search-controller.d.ts +1 -0
- package/dist/src/core/search-controller.js +3 -0
- package/dist/src/jobs/jobs.js +7 -0
- package/dist/src/routes/availability.d.ts +17 -5
- package/dist/src/routes/availability.js +85 -17
- package/package.json +1 -1
package/dist/src/chat/data.js
CHANGED
|
@@ -14,19 +14,24 @@ export function newChatData(type, payload) {
|
|
|
14
14
|
type,
|
|
15
15
|
payload,
|
|
16
16
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
17
|
+
// JSON snapshots also accept reactive UI objects without structuredClone's
|
|
18
|
+
// Proxy failure. Validation and size limits are shared with sending.
|
|
19
|
+
return JSON.parse(serializeChatData(data));
|
|
20
20
|
}
|
|
21
21
|
export function encodeChatContent(text, data) {
|
|
22
22
|
if (!data)
|
|
23
23
|
return text;
|
|
24
|
-
if (!text.trim() || text.includes(chatDataMarker)
|
|
24
|
+
if (!text.trim() || text.includes(chatDataMarker))
|
|
25
|
+
throw errValidation({ msg: msgChatData });
|
|
26
|
+
return text.trim() + chatDataMarker + serializeChatData(data);
|
|
27
|
+
}
|
|
28
|
+
function serializeChatData(data) {
|
|
29
|
+
if (!validChatData(data))
|
|
25
30
|
throw errValidation({ msg: msgChatData });
|
|
26
31
|
const value = JSON.stringify(data);
|
|
27
32
|
if (new TextEncoder().encode(value).length > chatDataMaximumBytes)
|
|
28
33
|
throw errValidation({ msg: msgChatData });
|
|
29
|
-
return
|
|
34
|
+
return value;
|
|
30
35
|
}
|
|
31
36
|
export function decodeChatContent(value) {
|
|
32
37
|
const boundary = value.indexOf(chatDataMarker);
|
package/dist/src/chat/forms.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { User } from "@fabriktor/schema";
|
|
2
2
|
import type { EmploymentActor } from "../contacts/scope.js";
|
|
3
|
+
import { type ChatData, type ChatJSON } from "./data.js";
|
|
3
4
|
export type ChatFormRecipient = Pick<User, "id" | "account_identity">;
|
|
4
5
|
export type ChatFormPolicy = {
|
|
5
6
|
readonly sender_modes: readonly User["active_mode"][];
|
|
6
7
|
readonly recipient_modes: readonly User["active_mode"][];
|
|
7
8
|
readonly allow_group: boolean;
|
|
8
9
|
};
|
|
10
|
+
export type ChatFormContextDefinition<T extends ChatJSON> = {
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly read: (value: ChatJSON | undefined) => T | undefined;
|
|
13
|
+
};
|
|
14
|
+
export declare function newChatFormData<T extends ChatJSON>(type: string, payload: ChatData["payload"], definition: ChatFormContextDefinition<T>, context: T): ChatData;
|
|
15
|
+
export declare function readChatFormContext<T extends ChatJSON>(data: ChatData, definition: ChatFormContextDefinition<T>): T;
|
|
9
16
|
export declare function canSendChatForm(policy: ChatFormPolicy, actor: EmploymentActor, recipients: readonly ChatFormRecipient[]): boolean;
|
package/dist/src/chat/forms.js
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
import { errValidation } from "../core/err.js";
|
|
1
2
|
import { normalizeIDs, resolvedID } from "../core/id.js";
|
|
3
|
+
import { newChatData } from "./data.js";
|
|
2
4
|
import { isFabriktorUser, isOtherUserID, userSupportsMode, } from "../users/users.js";
|
|
5
|
+
const msgFormContext = "The form details are missing or unsupported. Ask the sender to send a new form.";
|
|
6
|
+
export function newChatFormData(type, payload, definition, context) {
|
|
7
|
+
const value = definition.read(context);
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
throw errValidation({ msg: msgFormContext });
|
|
10
|
+
return newChatData(type, {
|
|
11
|
+
...payload,
|
|
12
|
+
form_context: { version: definition.version, data: value },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function readChatFormContext(data, definition) {
|
|
16
|
+
const context = data.payload.form_context;
|
|
17
|
+
if (context &&
|
|
18
|
+
typeof context === "object" &&
|
|
19
|
+
!Array.isArray(context) &&
|
|
20
|
+
context.version === definition.version) {
|
|
21
|
+
const value = definition.read(context.data);
|
|
22
|
+
if (value !== undefined)
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
throw errValidation({ msg: msgFormContext });
|
|
26
|
+
}
|
|
3
27
|
// Recipients are all other members of the conversation, resolved before use.
|
|
4
28
|
// Sender modes apply now; recipient modes describe what their identity supports.
|
|
5
29
|
// Do not depend on the recipient's current mode, which Public() omits.
|
|
@@ -48,6 +48,7 @@ export type ChatSendQueueOptions = {
|
|
|
48
48
|
upload: (entry: QueuedChat, signal: AbortSignal) => Promise<boolean>;
|
|
49
49
|
changed?: (chat?: Chat) => void;
|
|
50
50
|
online?: () => boolean;
|
|
51
|
+
on_error?: (error: unknown) => void;
|
|
51
52
|
};
|
|
52
53
|
export declare class ChatSendQueue {
|
|
53
54
|
private readonly opts;
|
|
@@ -87,6 +88,7 @@ export declare class ChatSendQueue {
|
|
|
87
88
|
private startConfirmation;
|
|
88
89
|
private startUpload;
|
|
89
90
|
private recoverUpload;
|
|
91
|
+
private report;
|
|
90
92
|
private waitForRetry;
|
|
91
93
|
private recoverStorage;
|
|
92
94
|
private scheduleRecovery;
|
|
@@ -4,7 +4,7 @@
|
|
|
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
6
|
import { requiredOperationID } from "../core/id.js";
|
|
7
|
-
import { createEvent, EventBus } from "../core/events.js";
|
|
7
|
+
import { createEvent, EventBus, notifyObserver, } from "../core/events.js";
|
|
8
8
|
import { PersistentQueue, QueueWakeup, nextQueueRetry, } from "../core/queue.js";
|
|
9
9
|
import { newOptimisticChat } from "./optimistic.js";
|
|
10
10
|
export const ChatSendStatus = {
|
|
@@ -141,6 +141,7 @@ export class ChatSendQueue {
|
|
|
141
141
|
this.pendingSaves.set(entry.id, failed);
|
|
142
142
|
this.replace(failed);
|
|
143
143
|
if (entry.owner_id === this.owner) {
|
|
144
|
+
this.report(err);
|
|
144
145
|
this.error = err;
|
|
145
146
|
this.publish();
|
|
146
147
|
}
|
|
@@ -198,7 +199,9 @@ export class ChatSendQueue {
|
|
|
198
199
|
}
|
|
199
200
|
for (const entry of stored) {
|
|
200
201
|
if (chatSendCanConfirm(entry) ||
|
|
201
|
-
(!entry.chat &&
|
|
202
|
+
(!entry.chat &&
|
|
203
|
+
entry.server_id &&
|
|
204
|
+
this.confirmed.has(entry.server_id))) {
|
|
202
205
|
this.startConfirmation(entry, owner, signal);
|
|
203
206
|
}
|
|
204
207
|
if (entry.chat && entry.status === ChatSendStatus.Uploading) {
|
|
@@ -208,6 +211,7 @@ export class ChatSendQueue {
|
|
|
208
211
|
}
|
|
209
212
|
catch (err) {
|
|
210
213
|
if (this.current(owner, signal)) {
|
|
214
|
+
this.report(err);
|
|
211
215
|
this.error = err;
|
|
212
216
|
this.publish();
|
|
213
217
|
this.recoverStorage();
|
|
@@ -321,6 +325,7 @@ export class ChatSendQueue {
|
|
|
321
325
|
select: (entry) => entry.input.chat_room_id === room && chatSendCanCreate(entry),
|
|
322
326
|
on_error: (err) => {
|
|
323
327
|
if (this.current(owner, signal)) {
|
|
328
|
+
this.report(err);
|
|
324
329
|
this.error = err;
|
|
325
330
|
this.publish();
|
|
326
331
|
this.recoverStorage();
|
|
@@ -390,12 +395,14 @@ export class ChatSendQueue {
|
|
|
390
395
|
}
|
|
391
396
|
this.startConfirmation(current, owner, signal);
|
|
392
397
|
}
|
|
393
|
-
catch {
|
|
398
|
+
catch (error) {
|
|
394
399
|
// remove() owns deliberate cancellation and removes the durable entry
|
|
395
400
|
// under this same room lock. Do not publish a transient send failure.
|
|
396
401
|
if (this.cancellations.has(entry.id) && !current.server_id) {
|
|
397
402
|
return false;
|
|
398
403
|
}
|
|
404
|
+
if (this.current(owner, signal))
|
|
405
|
+
this.report(error);
|
|
399
406
|
current = {
|
|
400
407
|
...current,
|
|
401
408
|
attempted: current.attempted && (requestStarted || !!current.server_id),
|
|
@@ -409,6 +416,7 @@ export class ChatSendQueue {
|
|
|
409
416
|
this.pendingCreates.delete(current.id);
|
|
410
417
|
}
|
|
411
418
|
catch (storageError) {
|
|
419
|
+
this.report(storageError);
|
|
412
420
|
this.error = storageError;
|
|
413
421
|
this.publish();
|
|
414
422
|
this.recoverStorage();
|
|
@@ -487,7 +495,9 @@ export class ChatSendQueue {
|
|
|
487
495
|
this.opts.changed?.(chat);
|
|
488
496
|
}
|
|
489
497
|
}
|
|
490
|
-
catch {
|
|
498
|
+
catch (error) {
|
|
499
|
+
if (this.current(owner, signal))
|
|
500
|
+
this.report(error);
|
|
491
501
|
await this.save({
|
|
492
502
|
...current,
|
|
493
503
|
retry: undefined,
|
|
@@ -501,6 +511,7 @@ export class ChatSendQueue {
|
|
|
501
511
|
})
|
|
502
512
|
.catch((err) => {
|
|
503
513
|
if (this.current(owner, signal)) {
|
|
514
|
+
this.report(err);
|
|
504
515
|
this.error = err;
|
|
505
516
|
this.publish();
|
|
506
517
|
this.recoverStorage();
|
|
@@ -563,7 +574,9 @@ export class ChatSendQueue {
|
|
|
563
574
|
this.publish();
|
|
564
575
|
this.opts.changed?.(this.confirmed.get(entry.server_id));
|
|
565
576
|
}
|
|
566
|
-
catch {
|
|
577
|
+
catch (error) {
|
|
578
|
+
if (this.current(owner, signal))
|
|
579
|
+
this.report(error);
|
|
567
580
|
await this.save({
|
|
568
581
|
...entry,
|
|
569
582
|
status: ChatSendStatus.UploadInterrupted,
|
|
@@ -572,6 +585,7 @@ export class ChatSendQueue {
|
|
|
572
585
|
})
|
|
573
586
|
.catch((err) => {
|
|
574
587
|
if (this.current(owner, signal)) {
|
|
588
|
+
this.report(err);
|
|
575
589
|
this.error = err;
|
|
576
590
|
this.publish();
|
|
577
591
|
}
|
|
@@ -616,6 +630,7 @@ export class ChatSendQueue {
|
|
|
616
630
|
if (!this.current(owner, signal)) {
|
|
617
631
|
return;
|
|
618
632
|
}
|
|
633
|
+
this.report(err);
|
|
619
634
|
this.error = err;
|
|
620
635
|
}
|
|
621
636
|
await this.save({
|
|
@@ -625,12 +640,17 @@ export class ChatSendQueue {
|
|
|
625
640
|
})
|
|
626
641
|
.catch((err) => {
|
|
627
642
|
if (this.current(owner, signal)) {
|
|
643
|
+
this.report(err);
|
|
628
644
|
this.error = err;
|
|
629
645
|
this.publish();
|
|
630
646
|
}
|
|
631
647
|
})
|
|
632
648
|
.finally(() => this.uploads.delete(entry.id));
|
|
633
649
|
}
|
|
650
|
+
report(error) {
|
|
651
|
+
if (this.opts.on_error)
|
|
652
|
+
notifyObserver(() => this.opts.on_error?.(error));
|
|
653
|
+
}
|
|
634
654
|
waitForRetry(entry) {
|
|
635
655
|
if (!entry.retry || entry.retry.after <= Date.now())
|
|
636
656
|
return false;
|
|
@@ -5,6 +5,8 @@ export type EventKey<T> = symbol & {
|
|
|
5
5
|
export type EventListener<T> = (value: T) => void | Promise<void>;
|
|
6
6
|
export declare function createEvent<T>(): EventKey<T>;
|
|
7
7
|
export declare function notifyObserver(run: () => unknown): void;
|
|
8
|
+
type ObserverErrorHandler = (error: unknown) => void;
|
|
9
|
+
export declare function setObserverErrorHandler(handler: ObserverErrorHandler): () => void;
|
|
8
10
|
export declare class EventBus {
|
|
9
11
|
private readonly listeners;
|
|
10
12
|
subscribe<T>(event: EventKey<T>, listener: EventListener<NoInfer<T>>, initial?: () => T): () => void;
|
package/dist/src/core/events.js
CHANGED
|
@@ -14,9 +14,28 @@ export function notifyObserver(run) {
|
|
|
14
14
|
observerError(err);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
function
|
|
17
|
+
function defaultObserverError(err) {
|
|
18
18
|
console.error("Event observer failed.", err);
|
|
19
19
|
}
|
|
20
|
+
let observerErrorHandler = defaultObserverError;
|
|
21
|
+
// Apps own diagnostic presentation; independent FX consumers keep the existing
|
|
22
|
+
// console fallback. Observer failures must never break a completed mutation.
|
|
23
|
+
export function setObserverErrorHandler(handler) {
|
|
24
|
+
const previous = observerErrorHandler;
|
|
25
|
+
observerErrorHandler = handler;
|
|
26
|
+
return () => {
|
|
27
|
+
if (observerErrorHandler === handler)
|
|
28
|
+
observerErrorHandler = previous;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function observerError(err) {
|
|
32
|
+
try {
|
|
33
|
+
observerErrorHandler(err);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
defaultObserverError(err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
20
39
|
export class EventBus {
|
|
21
40
|
listeners = new Map();
|
|
22
41
|
subscribe(event, listener, initial) {
|
|
@@ -11,6 +11,7 @@ export type SearchState<T> = {
|
|
|
11
11
|
export type SearchControllerOptions<T, Options extends SearchOptions> = {
|
|
12
12
|
search: (opts: Options) => Promise<SearchPage<T>>;
|
|
13
13
|
on_change: (state: SearchState<T>) => void;
|
|
14
|
+
on_error?: (error: unknown) => void;
|
|
14
15
|
};
|
|
15
16
|
export declare class SearchController<T extends SearchResultWithID, Options extends SearchOptions> {
|
|
16
17
|
private readonly options;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
6
|
import { appendUniqueSearchResults, normalizeSearchPage, } from "./search.js";
|
|
7
7
|
import { errUnknown } from "./err.js";
|
|
8
|
+
import { notifyObserver } from "./events.js";
|
|
8
9
|
// Holds only the current selection and its displayed pages. Previous queries
|
|
9
10
|
// are discarded, and revisiting a selection always makes a backend request.
|
|
10
11
|
export class SearchController {
|
|
@@ -96,6 +97,8 @@ export class SearchController {
|
|
|
96
97
|
loading_more: false,
|
|
97
98
|
error: errUnknown(error),
|
|
98
99
|
});
|
|
100
|
+
if (this.options.on_error)
|
|
101
|
+
notifyObserver(() => this.options.on_error?.(error));
|
|
99
102
|
return false;
|
|
100
103
|
}
|
|
101
104
|
finally {
|
package/dist/src/jobs/jobs.js
CHANGED
|
@@ -12,6 +12,7 @@ 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.";
|
|
14
14
|
const msgLocationIDRequired = "Location ID is required.";
|
|
15
|
+
const msgLocationClientRequired = "Choose a client for this location.";
|
|
15
16
|
const msgOwnerIDRequired = "Owner ID is required.";
|
|
16
17
|
const msgMemberIDRequired = "Member ID is required.";
|
|
17
18
|
const msgJobLocationMismatch = "Job location does not match the provided parent location.";
|
|
@@ -59,9 +60,11 @@ export class JobsFX {
|
|
|
59
60
|
this.now = opts.now ?? (() => new globalThis.Date());
|
|
60
61
|
}
|
|
61
62
|
async createLocation(opts) {
|
|
63
|
+
const client_id = requiredID(opts.in.client_id, "client_id", msgLocationClientRequired);
|
|
62
64
|
const out = await this.jobs.insertOneLocation({
|
|
63
65
|
in: {
|
|
64
66
|
...opts.in,
|
|
67
|
+
client_id,
|
|
65
68
|
is_archived: false,
|
|
66
69
|
current_members: normalizeIDs(opts.in.current_members),
|
|
67
70
|
},
|
|
@@ -72,8 +75,12 @@ export class JobsFX {
|
|
|
72
75
|
async updateLocation(opts) {
|
|
73
76
|
const owner_id = requiredID(opts.owner_id, "owner_id", msgOwnerIDRequired);
|
|
74
77
|
const id = requiredID(opts.location.id, "location", msgLocationIDRequired);
|
|
78
|
+
const client_id = requiredID(opts.in.client_id === undefined
|
|
79
|
+
? opts.location.client_id
|
|
80
|
+
: opts.in.client_id, "client_id", msgLocationClientRequired);
|
|
75
81
|
const input = {
|
|
76
82
|
...opts.in,
|
|
83
|
+
client_id,
|
|
77
84
|
...locationAddressInput(opts.location),
|
|
78
85
|
};
|
|
79
86
|
if (opts.in.current_members !== undefined) {
|
|
@@ -2,7 +2,7 @@ import type { JobsOperator, RoutesOperator, UsersOperator } from "@fabriktor/cli
|
|
|
2
2
|
import { type ClientAvailability, type InClientAvailability, type Location, type PrefCompany, type User } from "@fabriktor/schema";
|
|
3
3
|
import type { PreferencesFXOperator } from "../preferences/preferences.js";
|
|
4
4
|
import { type ChatData } from "../chat/data.js";
|
|
5
|
-
import { type ChatFormRecipient } from "../chat/forms.js";
|
|
5
|
+
import { type ChatFormContextDefinition, type ChatFormRecipient } from "../chat/forms.js";
|
|
6
6
|
import { type RoutesActor } from "./workspace.js";
|
|
7
7
|
export declare const clientAvailabilityMinimumWindows = 3;
|
|
8
8
|
export declare const availabilityRequestPolicy: {
|
|
@@ -21,23 +21,35 @@ export type AvailabilityContext = {
|
|
|
21
21
|
location: Location;
|
|
22
22
|
employer: User;
|
|
23
23
|
client: User;
|
|
24
|
-
|
|
24
|
+
working_hours?: AvailabilityWorkingHours;
|
|
25
25
|
timezone: string;
|
|
26
26
|
};
|
|
27
27
|
export type AvailabilityReaders = {
|
|
28
28
|
jobs: Pick<JobsOperator, "findOneLocation">;
|
|
29
29
|
users: Pick<UsersOperator, "findOneUser">;
|
|
30
|
-
|
|
30
|
+
};
|
|
31
|
+
export type AvailabilityWorkingHours = Pick<PrefCompany, "work_day_start_hour" | "work_day_end_hour">;
|
|
32
|
+
export type AvailabilityFormContext = {
|
|
33
|
+
working_hours: AvailabilityWorkingHours;
|
|
34
|
+
timezone: Location["address_timezone"];
|
|
35
|
+
};
|
|
36
|
+
export declare const availabilityFormContextDefinition: ChatFormContextDefinition<AvailabilityFormContext>;
|
|
37
|
+
export type AvailabilityContextOptions = {
|
|
38
|
+
request?: ChatData;
|
|
39
|
+
signal?: AbortSignal;
|
|
31
40
|
};
|
|
32
41
|
export declare function availabilityReference(data: ChatData): AvailabilityReference | undefined;
|
|
33
42
|
export declare function availabilityRequest(location: Location, actor: RoutesActor): ChatData;
|
|
34
43
|
export declare function canSendAvailabilityRequest(data: ChatData, actor: RoutesActor, recipients: readonly ChatFormRecipient[]): boolean;
|
|
44
|
+
export declare function loadAvailabilityRequestContext(readers: AvailabilityReaders & {
|
|
45
|
+
preferences: Pick<PreferencesFXOperator, "getPrefCompany">;
|
|
46
|
+
}, actor: RoutesActor, data: ChatData): Promise<AvailabilityFormContext>;
|
|
35
47
|
export declare function availabilitySubmission(reference: AvailabilityReference, request_id: string, ids: readonly string[]): ChatData;
|
|
36
|
-
export declare function loadAvailabilityContext(readers: AvailabilityReaders, actor: RoutesActor, reference: AvailabilityReference, signal?:
|
|
48
|
+
export declare function loadAvailabilityContext(readers: AvailabilityReaders, actor: RoutesActor, reference: AvailabilityReference, { request, signal }?: AvailabilityContextOptions): Promise<AvailabilityContext>;
|
|
37
49
|
export declare function validateAvailabilityWindows(inputs: readonly InClientAvailability[], context: AvailabilityContext, minimum?: number): void;
|
|
38
50
|
export declare function loadAvailabilitySelection(readers: AvailabilityReaders & {
|
|
39
51
|
routes: Pick<RoutesOperator, "findOneClientAvailability">;
|
|
40
|
-
}, actor: RoutesActor, reference: AvailabilityReference, ids: readonly string[],
|
|
52
|
+
}, actor: RoutesActor, reference: AvailabilityReference, ids: readonly string[], options?: AvailabilityContextOptions): Promise<{
|
|
41
53
|
context: AvailabilityContext;
|
|
42
54
|
windows: ClientAvailability[];
|
|
43
55
|
}>;
|
|
@@ -5,7 +5,7 @@ import { hoursPerDay, minutesOfDay, minutesPerHour, sameCalendarDay, } from "../
|
|
|
5
5
|
import { errValidation } from "../core/err.js";
|
|
6
6
|
import { normalizeIDs, resolvedID } from "../core/id.js";
|
|
7
7
|
import { newChatData } from "../chat/data.js";
|
|
8
|
-
import { canSendChatForm, } from "../chat/forms.js";
|
|
8
|
+
import { canSendChatForm, readChatFormContext, } from "../chat/forms.js";
|
|
9
9
|
import { routeCanPlan } from "./workspace.js";
|
|
10
10
|
import { validateClientAvailabilityInput } from "./validation.js";
|
|
11
11
|
export const clientAvailabilityMinimumWindows = 3;
|
|
@@ -18,10 +18,35 @@ export const RoutesChatDataType = {
|
|
|
18
18
|
AvailabilityRequest: "routes.availability.request",
|
|
19
19
|
AvailabilitySubmitted: "routes.availability.submitted",
|
|
20
20
|
};
|
|
21
|
+
export const availabilityFormContextDefinition = {
|
|
22
|
+
version: 1,
|
|
23
|
+
read: (value) => {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
25
|
+
return;
|
|
26
|
+
const { working_hours, timezone } = value;
|
|
27
|
+
if (!working_hours ||
|
|
28
|
+
typeof working_hours !== "object" ||
|
|
29
|
+
Array.isArray(working_hours) ||
|
|
30
|
+
typeof timezone !== "string" ||
|
|
31
|
+
!timezone.trim())
|
|
32
|
+
return;
|
|
33
|
+
const { work_day_start_hour: start, work_day_end_hour: end } = working_hours;
|
|
34
|
+
if (typeof start !== "number" ||
|
|
35
|
+
typeof end !== "number" ||
|
|
36
|
+
![start, end].every((hour) => Number.isInteger(hour) && hour >= 0 && hour < hoursPerDay) ||
|
|
37
|
+
start >= end)
|
|
38
|
+
return;
|
|
39
|
+
return {
|
|
40
|
+
timezone,
|
|
41
|
+
working_hours: { work_day_start_hour: start, work_day_end_hour: end },
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
21
45
|
const msgAvailabilityAccess = "This availability does not match the client and employer of the location.";
|
|
22
46
|
const msgAvailabilityTimezone = "The location needs a valid saved address timezone.";
|
|
47
|
+
const msgAvailabilityTimezoneChanged = "The location timezone has changed. Ask the sender to send a new form.";
|
|
23
48
|
const msgAvailabilityHours = "The employer's working hours must form a valid same-day window.";
|
|
24
|
-
const msgAvailabilityWindows = "Provide enough distinct, non-overlapping availability windows
|
|
49
|
+
const msgAvailabilityWindows = "Provide enough distinct, non-overlapping availability windows and respect the hours supplied with the request.";
|
|
25
50
|
const msgAvailabilityClient = "Visit times can only be requested from clients with a Fabriktor account. Arrange external contacts’ visit times directly.";
|
|
26
51
|
const msgAvailabilityData = "The availability request is invalid.";
|
|
27
52
|
export function availabilityReference(data) {
|
|
@@ -69,6 +94,30 @@ export function canSendAvailabilityRequest(data, actor, recipients) {
|
|
|
69
94
|
canSendChatForm(availabilityRequestPolicy, actor, recipients) &&
|
|
70
95
|
reference.client_id === recipients[0].id);
|
|
71
96
|
}
|
|
97
|
+
// Registry pre-action: only the employer preparing the request reads their
|
|
98
|
+
// preferences. Transfer the two hours, never the complete preferences record.
|
|
99
|
+
export async function loadAvailabilityRequestContext(readers, actor, data) {
|
|
100
|
+
const reference = availabilityReference(data);
|
|
101
|
+
if (data.type !== RoutesChatDataType.AvailabilityRequest ||
|
|
102
|
+
!reference ||
|
|
103
|
+
!routeCanPlan(actor) ||
|
|
104
|
+
reference.employer_id !== actor.owner_id)
|
|
105
|
+
throw errValidation({ msg: msgAvailabilityAccess });
|
|
106
|
+
const [context, company] = await Promise.all([
|
|
107
|
+
loadAvailabilityContext(readers, actor, reference),
|
|
108
|
+
readers.preferences.getPrefCompany({ owner_id: actor.owner_id }),
|
|
109
|
+
]);
|
|
110
|
+
const supplied = availabilityFormContextDefinition.read({
|
|
111
|
+
timezone: context.timezone,
|
|
112
|
+
working_hours: {
|
|
113
|
+
work_day_start_hour: company.work_day_start_hour,
|
|
114
|
+
work_day_end_hour: company.work_day_end_hour,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
if (!supplied)
|
|
118
|
+
throw errValidation({ msg: msgAvailabilityHours });
|
|
119
|
+
return supplied;
|
|
120
|
+
}
|
|
72
121
|
export function availabilitySubmission(reference, request_id, ids) {
|
|
73
122
|
const data = newChatData(RoutesChatDataType.AvailabilitySubmitted, {
|
|
74
123
|
...reference,
|
|
@@ -79,7 +128,19 @@ export function availabilitySubmission(reference, request_id, ids) {
|
|
|
79
128
|
throw errValidation({ msg: msgAvailabilityData });
|
|
80
129
|
return data;
|
|
81
130
|
}
|
|
82
|
-
export async function loadAvailabilityContext(readers, actor, reference, signal) {
|
|
131
|
+
export async function loadAvailabilityContext(readers, actor, reference, { request, signal } = {}) {
|
|
132
|
+
const supplied = request
|
|
133
|
+
? readChatFormContext(request, availabilityFormContextDefinition)
|
|
134
|
+
: undefined;
|
|
135
|
+
if (request) {
|
|
136
|
+
const requestReference = availabilityReference(request);
|
|
137
|
+
if (request.type !== RoutesChatDataType.AvailabilityRequest ||
|
|
138
|
+
!requestReference ||
|
|
139
|
+
requestReference.employer_id !== reference.employer_id ||
|
|
140
|
+
requestReference.client_id !== reference.client_id ||
|
|
141
|
+
requestReference.location_id !== reference.location_id)
|
|
142
|
+
throw errValidation({ msg: msgAvailabilityAccess });
|
|
143
|
+
}
|
|
83
144
|
const location = (await readers.jobs.findOneLocation({ id: reference.location_id, signal })).value;
|
|
84
145
|
if (!location ||
|
|
85
146
|
location.id !== reference.location_id ||
|
|
@@ -90,10 +151,9 @@ export async function loadAvailabilityContext(readers, actor, reference, signal)
|
|
|
90
151
|
? actor.owner_id !== reference.client_id
|
|
91
152
|
: !routeCanPlan(actor) || actor.owner_id !== reference.employer_id))
|
|
92
153
|
throw errValidation({ msg: msgAvailabilityAccess });
|
|
93
|
-
const [result, clientResult
|
|
154
|
+
const [result, clientResult] = await Promise.all([
|
|
94
155
|
readers.users.findOneUser({ id: reference.employer_id, signal }),
|
|
95
156
|
readers.users.findOneUser({ id: reference.client_id, signal }),
|
|
96
|
-
readers.preferences.getPrefCompany({ owner_id: reference.employer_id }),
|
|
97
157
|
]);
|
|
98
158
|
const employer = result.value;
|
|
99
159
|
if (!employer ||
|
|
@@ -108,11 +168,15 @@ export async function loadAvailabilityContext(readers, actor, reference, signal)
|
|
|
108
168
|
const timezone = addressTimezone(location);
|
|
109
169
|
if (!timezone)
|
|
110
170
|
throw errValidation({ msg: msgAvailabilityTimezone });
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
if (supplied && supplied.timezone !== timezone)
|
|
172
|
+
throw errValidation({ msg: msgAvailabilityTimezoneChanged });
|
|
173
|
+
return {
|
|
174
|
+
location,
|
|
175
|
+
employer,
|
|
176
|
+
client,
|
|
177
|
+
working_hours: supplied?.working_hours,
|
|
178
|
+
timezone,
|
|
179
|
+
};
|
|
116
180
|
}
|
|
117
181
|
export function validateAvailabilityWindows(inputs, context, minimum = clientAvailabilityMinimumWindows) {
|
|
118
182
|
if (inputs.length < minimum)
|
|
@@ -122,10 +186,11 @@ export function validateAvailabilityWindows(inputs, context, minimum = clientAva
|
|
|
122
186
|
if (input.employer_id !== context.employer.id ||
|
|
123
187
|
input.location_id !== context.location.id ||
|
|
124
188
|
input.timezone !== context.timezone ||
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
189
|
+
(context.working_hours &&
|
|
190
|
+
(minutesOfDay(input.from) <
|
|
191
|
+
context.working_hours.work_day_start_hour * minutesPerHour ||
|
|
192
|
+
minutesOfDay(input.to) >
|
|
193
|
+
context.working_hours.work_day_end_hour * minutesPerHour)) ||
|
|
129
194
|
inputs
|
|
130
195
|
.slice(0, index)
|
|
131
196
|
.some((other) => sameCalendarDay(input.from, other.from) &&
|
|
@@ -136,13 +201,16 @@ export function validateAvailabilityWindows(inputs, context, minimum = clientAva
|
|
|
136
201
|
}
|
|
137
202
|
// A chat response contains references, never authority or an appointment confirmation.
|
|
138
203
|
// Reload each chosen record before preparing the response, including after a resumed batch.
|
|
139
|
-
export async function loadAvailabilitySelection(readers, actor, reference, ids,
|
|
204
|
+
export async function loadAvailabilitySelection(readers, actor, reference, ids, options = {}) {
|
|
140
205
|
if (ids.length < clientAvailabilityMinimumWindows ||
|
|
141
206
|
normalizeIDs(ids).length !== ids.length)
|
|
142
207
|
throw errValidation({ msg: msgAvailabilityData });
|
|
143
|
-
const context = await loadAvailabilityContext(readers, actor, reference,
|
|
208
|
+
const context = await loadAvailabilityContext(readers, actor, reference, options);
|
|
144
209
|
const windows = await Promise.all(ids.map(async (id) => {
|
|
145
|
-
const window = (await readers.routes.findOneClientAvailability({
|
|
210
|
+
const window = (await readers.routes.findOneClientAvailability({
|
|
211
|
+
id,
|
|
212
|
+
signal: options.signal,
|
|
213
|
+
})).value;
|
|
146
214
|
if (!window ||
|
|
147
215
|
window.id !== id ||
|
|
148
216
|
window.owner_id !== reference.client_id)
|