@fabriktor/fx 0.0.149 → 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
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.
|
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)
|