@fabriktor/fx 0.0.144 → 0.0.146

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.
@@ -65,7 +65,7 @@ export type MyChatRoomMembersOptions = {
65
65
  export type ChatRoomParticipantsOptions = {
66
66
  chat_room_ids: string[];
67
67
  };
68
- export type ChatRoomParticipant = Pick<User, "id" | "full_name" | "username">;
68
+ export type ChatRoomParticipant = Pick<User, "id" | "full_name" | "username" | "account_identity">;
69
69
  export type ChatRoomParticipants = {
70
70
  members: ChatRoomMember[];
71
71
  users: ChatRoomParticipant[];
@@ -6,8 +6,9 @@
6
6
  import { queryParam, queryParams, } from "@fabriktor/client";
7
7
  import { IDKey, OwnerIDKey, } from "@fabriktor/schema";
8
8
  import { errRequired, errResolveFailed, errValidation } from "../core/err.js";
9
- import { requiredOperationID } from "../core/id.js";
9
+ import { requiredOperationID, normalizeIDs } from "../core/id.js";
10
10
  import { normalizeSearchPage, normalizeSearchPerPage, normalizeSearchQuery, runSearch, } from "../core/search.js";
11
+ import { isFabriktorUser } from "../users/users.js";
11
12
  import { ChatSendQueue } from "./send-queue.js";
12
13
  const closeCodeNormal = 1000;
13
14
  const wsRefreshMilliseconds = 40 * 60 * 1000;
@@ -19,6 +20,7 @@ const msgEnsuredChatRoomIDMissing = "Ensured chat room response did not contain
19
20
  const msgChatMissing = "Chat could not be resolved after the mutation.";
20
21
  const msgSearchQueryRequired = "Search query is required.";
21
22
  const msgChatObjectsUploading = "Cannot react to a chat while objects are still uploading.";
23
+ const msgChatRecipients = "Chat recipients must have a Fabriktor account. External contacts cannot receive messages or forms.";
22
24
  const msgParticipantMissing = "Chat room participant could not be resolved.";
23
25
  export class ChatFX {
24
26
  chat;
@@ -36,7 +38,22 @@ export class ChatFX {
36
38
  return await this.findChat(id);
37
39
  }
38
40
  async ensureChatRoom(opts) {
39
- const out = await this.chat.ensureChatRoom(opts);
41
+ const members = normalizeIDs(opts.members);
42
+ if (!members.length)
43
+ throw errRequired({
44
+ field: "members",
45
+ msg: msgParticipantMissing,
46
+ });
47
+ const users = await Promise.all(members.map(async (id) => {
48
+ const user = (await this.users.findOneUser({ id })).value;
49
+ return user?.id === id ? user : undefined;
50
+ }));
51
+ if (users.some((user) => !isFabriktorUser(user))) {
52
+ throw errValidation({
53
+ msg: msgChatRecipients,
54
+ });
55
+ }
56
+ const out = await this.chat.ensureChatRoom({ ...opts, members });
40
57
  return requiredOperationID(out, msgEnsuredChatRoomIDMissing);
41
58
  }
42
59
  async markChatObjectsUploaded(opts) {
@@ -456,6 +473,7 @@ export class ChatFX {
456
473
  id: user.id,
457
474
  full_name: user.full_name,
458
475
  username: user.username,
476
+ account_identity: user.account_identity,
459
477
  };
460
478
  }
461
479
  async findChat(id, signal) {
@@ -0,0 +1,9 @@
1
+ import type { User } from "@fabriktor/schema";
2
+ import type { EmploymentActor } from "../contacts/scope.js";
3
+ export type ChatFormRecipient = Pick<User, "id" | "account_identity">;
4
+ export type ChatFormPolicy = {
5
+ readonly sender_modes: readonly User["active_mode"][];
6
+ readonly recipient_identities: readonly User["account_identity"][];
7
+ readonly allow_group: boolean;
8
+ };
9
+ export declare function canSendChatForm(policy: ChatFormPolicy, actor: EmploymentActor, recipients: readonly ChatFormRecipient[]): boolean;
@@ -0,0 +1,15 @@
1
+ import { normalizeIDs, resolvedID } from "../core/id.js";
2
+ import { isFabriktorUser } from "../users/users.js";
3
+ // Recipients are all other members of the conversation, resolved before use.
4
+ // Recipient eligibility uses account identity, never their transient active mode.
5
+ export function canSendChatForm(policy, actor, recipients) {
6
+ return (!!resolvedID(actor.owner_id) &&
7
+ policy.sender_modes.includes(actor.active_mode) &&
8
+ recipients.length > 0 &&
9
+ (policy.allow_group || recipients.length === 1) &&
10
+ normalizeIDs(recipients.map((recipient) => recipient.id)).length ===
11
+ recipients.length &&
12
+ recipients.every((recipient) => recipient.id !== actor.owner_id &&
13
+ isFabriktorUser(recipient) &&
14
+ policy.recipient_identities.includes(recipient.account_identity)));
15
+ }
@@ -3,6 +3,7 @@ export * from "./billable/billable.js";
3
3
  export * from "./calls/calls.js";
4
4
  export * from "./chat/chat.js";
5
5
  export * from "./chat/data.js";
6
+ export * from "./chat/forms.js";
6
7
  export * from "./chat/optimistic.js";
7
8
  export * from "./chat/send-queue.js";
8
9
  export * from "./contacts/contacts.js";
package/dist/src/index.js CHANGED
@@ -3,6 +3,7 @@ export * from "./billable/billable.js";
3
3
  export * from "./calls/calls.js";
4
4
  export * from "./chat/chat.js";
5
5
  export * from "./chat/data.js";
6
+ export * from "./chat/forms.js";
6
7
  export * from "./chat/optimistic.js";
7
8
  export * from "./chat/send-queue.js";
8
9
  export * from "./contacts/contacts.js";
@@ -2,8 +2,14 @@ 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
6
  import { type RoutesActor } from "./workspace.js";
6
7
  export declare const clientAvailabilityMinimumWindows = 3;
8
+ export declare const availabilityRequestPolicy: {
9
+ readonly sender_modes: readonly ["employer"];
10
+ readonly recipient_identities: readonly ["client"];
11
+ readonly allow_group: false;
12
+ };
7
13
  export declare const RoutesChatDataType: {
8
14
  readonly AvailabilityRequest: "routes.availability.request";
9
15
  readonly AvailabilitySubmitted: "routes.availability.submitted";
@@ -14,6 +20,7 @@ export type AvailabilityReference = Pick<InClientAvailability, "employer_id" | "
14
20
  export type AvailabilityContext = {
15
21
  location: Location;
16
22
  employer: User;
23
+ client: User;
17
24
  company: PrefCompany;
18
25
  timezone: string;
19
26
  };
@@ -24,6 +31,7 @@ export type AvailabilityReaders = {
24
31
  };
25
32
  export declare function availabilityReference(data: ChatData): AvailabilityReference | undefined;
26
33
  export declare function availabilityRequest(location: Location, actor: RoutesActor): ChatData;
34
+ export declare function canSendAvailabilityRequest(data: ChatData, actor: RoutesActor, recipients: readonly ChatFormRecipient[]): boolean;
27
35
  export declare function availabilitySubmission(reference: AvailabilityReference, request_id: string, ids: readonly string[]): ChatData;
28
36
  export declare function loadAvailabilityContext(readers: AvailabilityReaders, actor: RoutesActor, reference: AvailabilityReference, signal?: AbortSignal): Promise<AvailabilityContext>;
29
37
  export declare function validateAvailabilityWindows(inputs: readonly InClientAvailability[], context: AvailabilityContext, minimum?: number): void;
@@ -1,12 +1,19 @@
1
1
  import { UserTypeClient, UserTypeEmployer, } from "@fabriktor/schema";
2
+ import { isFabriktorUser } from "../users/users.js";
2
3
  import { addressTimezone } from "../core/address.js";
3
4
  import { hoursPerDay, minutesOfDay, minutesPerHour, sameCalendarDay, } from "../core/date.js";
4
5
  import { errValidation } from "../core/err.js";
5
6
  import { normalizeIDs, resolvedID } from "../core/id.js";
6
7
  import { newChatData } from "../chat/data.js";
8
+ import { canSendChatForm, } from "../chat/forms.js";
7
9
  import { routeCanPlan } from "./workspace.js";
8
10
  import { validateClientAvailabilityInput } from "./validation.js";
9
11
  export const clientAvailabilityMinimumWindows = 3;
12
+ export const availabilityRequestPolicy = {
13
+ sender_modes: [UserTypeEmployer],
14
+ recipient_identities: [UserTypeClient],
15
+ allow_group: false,
16
+ };
10
17
  export const RoutesChatDataType = {
11
18
  AvailabilityRequest: "routes.availability.request",
12
19
  AvailabilitySubmitted: "routes.availability.submitted",
@@ -15,6 +22,7 @@ const msgAvailabilityAccess = "This availability does not match the client and e
15
22
  const msgAvailabilityTimezone = "The location needs a valid saved address timezone.";
16
23
  const msgAvailabilityHours = "The employer's working hours must form a valid same-day window.";
17
24
  const msgAvailabilityWindows = "Provide enough distinct, non-overlapping availability windows within the employer's working hours.";
25
+ const msgAvailabilityClient = "Visit times can only be requested from clients with a Fabriktor account. Arrange external contacts’ visit times directly.";
18
26
  const msgAvailabilityData = "The availability request is invalid.";
19
27
  export function availabilityReference(data) {
20
28
  if (data.type !== RoutesChatDataType.AvailabilityRequest &&
@@ -53,6 +61,14 @@ export function availabilityRequest(location, actor) {
53
61
  location_id: location.id,
54
62
  });
55
63
  }
64
+ export function canSendAvailabilityRequest(data, actor, recipients) {
65
+ const reference = availabilityReference(data);
66
+ return (data.type === RoutesChatDataType.AvailabilityRequest &&
67
+ !!reference &&
68
+ reference.employer_id === actor.owner_id &&
69
+ canSendChatForm(availabilityRequestPolicy, actor, recipients) &&
70
+ reference.client_id === recipients[0].id);
71
+ }
56
72
  export function availabilitySubmission(reference, request_id, ids) {
57
73
  const data = newChatData(RoutesChatDataType.AvailabilitySubmitted, {
58
74
  ...reference,
@@ -74,8 +90,9 @@ export async function loadAvailabilityContext(readers, actor, reference, signal)
74
90
  ? actor.owner_id !== reference.client_id
75
91
  : !routeCanPlan(actor) || actor.owner_id !== reference.employer_id))
76
92
  throw errValidation({ msg: msgAvailabilityAccess });
77
- const [result, company] = await Promise.all([
93
+ const [result, clientResult, company] = await Promise.all([
78
94
  readers.users.findOneUser({ id: reference.employer_id, signal }),
95
+ readers.users.findOneUser({ id: reference.client_id, signal }),
79
96
  readers.preferences.getPrefCompany({ owner_id: reference.employer_id }),
80
97
  ]);
81
98
  const employer = result.value;
@@ -83,6 +100,11 @@ export async function loadAvailabilityContext(readers, actor, reference, signal)
83
100
  employer.id !== reference.employer_id ||
84
101
  employer.account_identity !== UserTypeEmployer)
85
102
  throw errValidation({ msg: msgAvailabilityAccess });
103
+ const client = clientResult.value;
104
+ if (!client || client.id !== reference.client_id || !isFabriktorUser(client))
105
+ throw errValidation({
106
+ msg: msgAvailabilityClient,
107
+ });
86
108
  const timezone = addressTimezone(location);
87
109
  if (!timezone)
88
110
  throw errValidation({ msg: msgAvailabilityTimezone });
@@ -90,7 +112,7 @@ export async function loadAvailabilityContext(readers, actor, reference, signal)
90
112
  if (![start, end].every((hour) => Number.isInteger(hour) && hour >= 0 && hour < hoursPerDay) ||
91
113
  start >= end)
92
114
  throw errValidation({ msg: msgAvailabilityHours });
93
- return { location, employer, company, timezone };
115
+ return { location, employer, client, company, timezone };
94
116
  }
95
117
  export function validateAvailabilityWindows(inputs, context, minimum = clientAvailabilityMinimumWindows) {
96
118
  if (inputs.length < minimum)
@@ -10,6 +10,13 @@ export type ExternalUserInput = Omit<InUser, "account_identity" | "active_mode"
10
10
  export type CreateExternalUserOptions = {
11
11
  in: ExternalUserInput;
12
12
  };
13
+ export type UpdateExternalUserOptions = {
14
+ id: string;
15
+ owner_id: string;
16
+ in: ExternalUserInput;
17
+ };
18
+ export declare function isFabriktorUser(user: Pick<User, "account_identity"> | null | undefined): boolean;
19
+ export declare function canManageExternalUser(user: User | null | undefined, owner_id: string): boolean;
13
20
  export type SearchExternalUsersOptions = SearchUsersOptions;
14
21
  export type SearchExternalUsersPage = SearchUsersPage;
15
22
  export type UserProfileInput = Pick<InUser, "first_name" | "last_name" | "phone" | "company_name" | "privacy"> & InAddress;
@@ -43,6 +50,7 @@ export type UsersFXOptions = {
43
50
  export interface UsersFXOperator {
44
51
  searchUsers(opts: SearchUsersOptions): Promise<SearchUsersPage>;
45
52
  createExternalUser(opts: CreateExternalUserOptions): Promise<User>;
53
+ updateExternalUser(opts: UpdateExternalUserOptions): Promise<User>;
46
54
  searchExternalUsers(opts: SearchExternalUsersOptions): Promise<SearchExternalUsersPage>;
47
55
  updateUserProfile(opts: UpdateUserProfileOptions): Promise<User>;
48
56
  updateUserPhone(opts: UpdateUserPhoneOptions): Promise<User>;
@@ -61,6 +69,7 @@ export declare class UsersFX implements UsersFXOperator {
61
69
  constructor(opts: UsersFXOptions);
62
70
  searchUsers(opts: SearchUsersOptions): Promise<SearchUsersPage>;
63
71
  createExternalUser(opts: CreateExternalUserOptions): Promise<User>;
72
+ updateExternalUser(opts: UpdateExternalUserOptions): Promise<User>;
64
73
  searchExternalUsers(opts: SearchExternalUsersOptions): Promise<SearchExternalUsersPage>;
65
74
  updateUserProfile(opts: UpdateUserProfileOptions): Promise<User>;
66
75
  updateUserPhone(opts: UpdateUserPhoneOptions): Promise<User>;
@@ -5,7 +5,7 @@
5
5
  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
6
  import { UserTypeClient, UserTypeEmployee, UserTypeEmployer, UserTypeExternal, } from "@fabriktor/schema";
7
7
  import { errRequired, errResolveFailed, errValidation, } from "../core/err.js";
8
- import { requiredOperationID } from "../core/id.js";
8
+ import { requiredOperationID, resolvedID } from "../core/id.js";
9
9
  import { runSearch, } from "../core/search.js";
10
10
  import { normalizePhone, normalizePhoneCode, validatePhone, validatePhoneCode, } from "./phone.js";
11
11
  import { normalizeEmail, isValidEmail } from "./email.js";
@@ -20,12 +20,24 @@ const msgEmailInvalid = "Email address is invalid.";
20
20
  const msgAddressRequired = "A complete address is required.";
21
21
  const msgUserMissingAfterUpdate = "User could not be resolved after the account update.";
22
22
  const msgCreatedExternalUserIDMissing = "Created external user response did not contain an ID.";
23
+ const msgExternalUserAccess = "Only the owner can edit an external contact.";
23
24
  const msgExternalUserMissing = "External user could not be resolved after creation.";
24
25
  const msgInvalidActiveMode = "Active mode is not available for this account identity.";
25
26
  const msgWorkIdentityRequiresClient = "A work identity can only be established from a client account.";
26
27
  const msgInvalidWorkIdentity = "Work identity must be employee or employer.";
27
28
  const resourceUsername = "username";
28
29
  const resourceUser = "user";
30
+ // External records are private contacts, not messaging accounts.
31
+ export function isFabriktorUser(user) {
32
+ return (user?.account_identity === UserTypeClient ||
33
+ user?.account_identity === UserTypeEmployee ||
34
+ user?.account_identity === UserTypeEmployer);
35
+ }
36
+ export function canManageExternalUser(user, owner_id) {
37
+ return (!!resolvedID(owner_id) &&
38
+ user?.account_identity === UserTypeExternal &&
39
+ user.external_owner_id === owner_id);
40
+ }
29
41
  export class UsersFX {
30
42
  users;
31
43
  tmp_usernames;
@@ -62,6 +74,38 @@ export class UsersFX {
62
74
  const id = requiredOperationID(out, msgCreatedExternalUserIDMissing);
63
75
  return await this.findExternalUser(id);
64
76
  }
77
+ async updateExternalUser(opts) {
78
+ const current = await this.findExternalUser(opts.id);
79
+ if (!canManageExternalUser(current, opts.owner_id)) {
80
+ throw errValidation({
81
+ msg: msgExternalUserAccess,
82
+ });
83
+ }
84
+ const input = normalizeExternalUserInput(opts.in);
85
+ validateExternalUserInput(input);
86
+ // Only editable contact fields; preserve identity, privacy and account metadata.
87
+ await this.users.replaceOneUser({
88
+ id: current.id,
89
+ in: {
90
+ first_name: input.first_name,
91
+ last_name: input.last_name,
92
+ email: input.email,
93
+ phone: input.phone,
94
+ company_name: input.company_name,
95
+ lat: input.lat,
96
+ lon: input.lon,
97
+ street: input.street,
98
+ city: input.city,
99
+ state: input.state,
100
+ postal_code: input.postal_code,
101
+ country: input.country,
102
+ formatted_address: input.formatted_address,
103
+ place_name: input.place_name,
104
+ place_id: input.place_id,
105
+ },
106
+ });
107
+ return await this.findExternalUser(current.id);
108
+ }
65
109
  async searchExternalUsers(opts) {
66
110
  const { uid, ...searchOpts } = opts;
67
111
  return await runSearch({
@@ -181,7 +225,7 @@ export class UsersFX {
181
225
  async findExternalUser(id) {
182
226
  const out = await this.users.findOneUser({ id });
183
227
  const user = out.value;
184
- if (user === undefined) {
228
+ if (!user || user.id !== id || user.account_identity !== UserTypeExternal) {
185
229
  throw errResolveFailed({
186
230
  resource: resourceUser,
187
231
  msg: msgExternalUserMissing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.144",
3
+ "version": "0.0.146",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {