Skip to content

Commit 1d2bfd7

Browse files
authored
chore: refactor apps converters to TypeScript with Zod codecs (2/2) (#41205)
1 parent 5641644 commit 1d2bfd7

30 files changed

Lines changed: 1578 additions & 1175 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { IAppsLivechatContact } from '@rocket.chat/apps';
2+
import type { ILivechatContact } from '@rocket.chat/core-typings';
3+
import * as z from 'zod';
4+
5+
import { mappedDecodeAsync } from './mappedData';
6+
7+
/**
8+
* Rocket.Chat `ILivechatContact` <-> Apps-Engine `IAppsLivechatContact`.
9+
*
10+
* `decode` (Rocket.Chat -> Apps-Engine) is a straight clone, mirroring the legacy `convertContact`.
11+
* `encode` (Apps-Engine -> Rocket.Chat) maps every attribute individually via a nested field map so
12+
* no extra data coming from the app leaks through — mirroring the legacy `convertAppContact`.
13+
*/
14+
export const ContactCodec = z.codec(z.custom<ILivechatContact>(), z.custom<IAppsLivechatContact>(), {
15+
decode: (contact): IAppsLivechatContact => structuredClone(contact) as unknown as IAppsLivechatContact,
16+
encode: (contact): Promise<ILivechatContact> => {
17+
const map = {
18+
_id: '_id',
19+
_updatedAt: '_updatedAt',
20+
name: 'name',
21+
phones: {
22+
from: 'phones',
23+
list: true,
24+
map: {
25+
phoneNumber: 'phoneNumber',
26+
},
27+
},
28+
emails: {
29+
from: 'emails',
30+
list: true,
31+
map: {
32+
address: 'address',
33+
},
34+
},
35+
contactManager: 'contactManager',
36+
unknown: 'unknown',
37+
conflictingFields: {
38+
from: 'conflictingFields',
39+
list: true,
40+
map: {
41+
field: 'field',
42+
value: 'value',
43+
},
44+
},
45+
customFields: 'customFields',
46+
channels: {
47+
from: 'channels',
48+
list: true,
49+
map: {
50+
name: 'name',
51+
verified: 'verified',
52+
visitor: {
53+
from: 'visitor',
54+
map: {
55+
visitorId: 'visitorId',
56+
source: {
57+
from: 'source',
58+
map: {
59+
type: 'type',
60+
id: 'id',
61+
},
62+
},
63+
},
64+
},
65+
blocked: 'blocked',
66+
field: 'field',
67+
value: 'value',
68+
verifiedAt: 'verifiedAt',
69+
details: {
70+
from: 'details',
71+
map: {
72+
type: 'type',
73+
id: 'id',
74+
alias: 'alias',
75+
label: 'label',
76+
sidebarIcon: 'sidebarIcon',
77+
defaultIcon: 'defaultIcon',
78+
destination: 'destination',
79+
},
80+
},
81+
lastChat: {
82+
from: 'lastChat',
83+
map: {
84+
_id: '_id',
85+
ts: 'ts',
86+
},
87+
},
88+
},
89+
},
90+
createdAt: 'createdAt',
91+
lastChat: {
92+
from: 'lastChat',
93+
map: {
94+
_id: '_id',
95+
ts: 'ts',
96+
},
97+
},
98+
importIds: 'importIds',
99+
};
100+
101+
return mappedDecodeAsync<ILivechatContact>(contact, map, { dropUnmapped: true });
102+
},
103+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { IAppsDepartment } from '@rocket.chat/apps';
2+
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
3+
import * as z from 'zod';
4+
5+
import { mappedDecode } from './mappedData';
6+
7+
/**
8+
* Rocket.Chat `ILivechatDepartment` <-> Apps-Engine `IAppsDepartment`.
9+
*
10+
* `decode` is a plain field-rename with an `_unmappedProperties_` bucket (mirrors the legacy
11+
* `convertDepartment`). `encode` mirrors `convertAppDepartment`: the inverse rename written
12+
* unconditionally, then `_unmappedProperties_` merged on top.
13+
*/
14+
export const DepartmentCodec = z.codec(z.custom<ILivechatDepartment>(), z.custom<IAppsDepartment>(), {
15+
decode: mappedDecode<ILivechatDepartment>({
16+
id: '_id',
17+
name: 'name',
18+
email: 'email',
19+
updatedAt: '_updatedAt',
20+
enabled: 'enabled',
21+
numberOfAgents: 'numAgents',
22+
showOnOfflineForm: 'showOnOfflineForm',
23+
description: 'description',
24+
offlineMessageChannelName: 'offlineMessageChannelName',
25+
requestTagBeforeClosingChat: 'requestTagBeforeClosingChat',
26+
chatClosingTags: 'chatClosingTags',
27+
abandonedRoomsCloseCustomMessage: 'abandonedRoomsCloseCustomMessage',
28+
waitingQueueMessage: 'waitingQueueMessage',
29+
departmentsAllowedToForward: 'departmentsAllowedToForward',
30+
showOnRegistration: 'showOnRegistration',
31+
}) as unknown as (department: ILivechatDepartment) => IAppsDepartment,
32+
encode: (department): ILivechatDepartment => {
33+
const newDepartment = {
34+
_id: department.id,
35+
name: department.name,
36+
email: department.email,
37+
_updatedAt: department.updatedAt,
38+
enabled: department.enabled,
39+
numAgents: department.numberOfAgents,
40+
showOnOfflineForm: department.showOnOfflineForm,
41+
showOnRegistration: department.showOnRegistration,
42+
description: department.description,
43+
offlineMessageChannelName: department.offlineMessageChannelName,
44+
requestTagBeforeClosingChat: department.requestTagBeforeClosingChat,
45+
chatClosingTags: department.chatClosingTags,
46+
abandonedRoomsCloseCustomMessage: department.abandonedRoomsCloseCustomMessage,
47+
waitingQueueMessage: department.waitingQueueMessage,
48+
departmentsAllowedToForward: department.departmentsAllowedToForward,
49+
};
50+
51+
return Object.assign(
52+
newDepartment,
53+
(department as { _unmappedProperties_?: Record<string, unknown> })._unmappedProperties_,
54+
) as unknown as ILivechatDepartment;
55+
},
56+
});

apps/meteor/app/apps/server/converters/codecs/enums.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import * as z from 'zod';
2323
* Rocket.Chat `IUser['type']` <-> Apps-Engine `UserType`.
2424
* Mirrors `AppUsersConverter._convertUserTypeToEnum`.
2525
*/
26-
export const UserTypeCodec = z.codec(z.string().optional(), z.string(), {
26+
export const UserTypeCodec = z.codec(z.any(), z.any(), {
2727
decode: (type): string => {
2828
switch (type) {
2929
case 'user':
@@ -36,7 +36,6 @@ export const UserTypeCodec = z.codec(z.string().optional(), z.string(), {
3636
case undefined:
3737
return UserType.UNKNOWN;
3838
default:
39-
// eslint-disable-next-line no-console
4039
console.warn(`A new user type has been added that the Apps don't know about? "${type}"`);
4140
return type.toUpperCase();
4241
}
@@ -51,7 +50,7 @@ export const UserTypeCodec = z.codec(z.string().optional(), z.string(), {
5150
* `console.warn` (which includes the affected user's id/username) stays in the converter, since
5251
* that context is not available to a standalone codec.
5352
*/
54-
export const UserStatusConnectionCodec = z.codec(z.string().optional(), z.string(), {
53+
export const UserStatusConnectionCodec = z.codec(z.any(), z.any(), {
5554
decode: (status): string => {
5655
switch (status) {
5756
case 'offline':
@@ -76,7 +75,7 @@ export const UserStatusConnectionCodec = z.codec(z.string().optional(), z.string
7675
* Rocket.Chat `IRoom['t']` <-> Apps-Engine `RoomType`.
7776
* Mirrors `AppRoomsConverter._convertTypeToApp`. Unknown type characters pass through unchanged.
7877
*/
79-
export const RoomTypeCodec = z.codec(z.string(), z.string(), {
78+
export const RoomTypeCodec = z.codec(z.any(), z.any(), {
8079
decode: (typeChar): string => {
8180
switch (typeChar) {
8281
case 'c':
@@ -99,7 +98,7 @@ export const RoomTypeCodec = z.codec(z.string(), z.string(), {
9998
* Rocket.Chat `ISetting['type']` <-> Apps-Engine `SettingType`.
10099
* Mirrors `AppSettingsConverter._convertTypeToApp`. Unknown types pass through unchanged.
101100
*/
102-
export const SettingTypeCodec = z.codec(z.string(), z.string(), {
101+
export const SettingTypeCodec = z.codec(z.any(), z.any(), {
103102
decode: (type): string => {
104103
switch (type) {
105104
case 'boolean':
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
export { RoomTypeCodec, SettingTypeCodec, UserStatusConnectionCodec, UserTypeCodec } from './enums';
2+
export { createMappedCodec, mappedDecode, mappedEncode } from './mappedData';
3+
export type { FieldMap } from './mappedData';
4+
export { SettingCodec } from './settings';
5+
export { RoleCodec } from './roles';
6+
export { VideoConferenceCodec } from './videoConferences';
7+
export { VisitorCodec } from './visitors';
8+
export { DepartmentCodec } from './departments';
9+
export { UserCodec } from './users';
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as z from 'zod';
2+
3+
type Loose = Record<string, any>;
4+
5+
/**
6+
* A declarative field map from Apps-Engine (target) property names to Rocket.Chat (source) property
7+
* names. Values are constrained to the keys of `Source`, so mapping a renamed or misspelled source
8+
* field is a compile error.
9+
*/
10+
export type FieldMap<Source> = Record<string, Extract<keyof Source, string>>;
11+
12+
/**
13+
* The Rocket.Chat -> Apps-Engine result of decoding `Source` through `Map`: each target property
14+
* holds its source value (optional, since it is only set when the source value is defined), and
15+
* everything the map did not name is collected under `_unmappedProperties_`.
16+
*/
17+
export type Decoded<Source, Map extends FieldMap<Source>> = {
18+
-readonly [Target in keyof Map]?: Source[Map[Target]];
19+
} & {
20+
_unmappedProperties_: Omit<Source, Map[keyof Map]>;
21+
};
22+
23+
/**
24+
* Rocket.Chat -> Apps-Engine transform for a plain string field map, reproducing the "rename these
25+
* fields, bucket the rest" behaviour of the original mapping helper: the input is deep-cloned (so the
26+
* app can never mutate the stored document), each mapped source field is copied to its target name
27+
* when defined, and every remaining property is collected into `_unmappedProperties_`.
28+
*/
29+
export function mappedDecode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
30+
fieldMap: Map,
31+
): (data: Source) => Decoded<Source, Map> {
32+
const entries = Object.entries(fieldMap) as [string, string][];
33+
34+
return (data: Source): Decoded<Source, Map> => {
35+
const clone: Loose = structuredClone(data);
36+
const result: Loose = {};
37+
38+
for (const [target, sourceKey] of entries) {
39+
if (typeof clone[sourceKey] !== 'undefined') {
40+
result[target] = clone[sourceKey];
41+
}
42+
43+
delete clone[sourceKey];
44+
}
45+
46+
result._unmappedProperties_ = clone;
47+
48+
return result as Decoded<Source, Map>;
49+
};
50+
}
51+
52+
/**
53+
* The Apps-Engine -> Rocket.Chat inverse of {@link mappedDecode}: target fields are copied back to
54+
* their source names when defined and `_unmappedProperties_` is merged onto the result. Because both
55+
* the renamed keys and the bucket originate from `Source`, the result is a `Partial<Source>`.
56+
*
57+
* This is the symmetric counterpart to {@link mappedDecode}; converters whose reverse direction has
58+
* defaults, conditional fields or asymmetric mappings provide their own `encode`.
59+
*/
60+
export function mappedEncode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
61+
fieldMap: Map,
62+
): (app: Decoded<Source, Map>) => Partial<Source> {
63+
const entries = Object.entries(fieldMap) as [string, string][];
64+
65+
return (app: Decoded<Source, Map>): Partial<Source> => {
66+
const { _unmappedProperties_ = {}, ...rest } = app as Loose;
67+
const result: Loose = {};
68+
69+
for (const [target, sourceKey] of entries) {
70+
if (typeof rest[target] !== 'undefined') {
71+
result[sourceKey] = rest[target];
72+
}
73+
}
74+
75+
return { ...result, ..._unmappedProperties_ } as Partial<Source>;
76+
};
77+
}
78+
79+
/**
80+
* A map whose entries are one of the three forms the original mapping helper supports: a source
81+
* property name (string), a function that derives the target value from the (cloned) source data,
82+
* or a nested `{ from, map, list }` descriptor for sub-objects and arrays.
83+
*
84+
* Unlike {@link FieldMap}, this is intentionally loosely typed: its consumers (rooms, messages,
85+
* uploads) map many optional/livechat-only fields that are not part of the base document types.
86+
*/
87+
export type AsyncFieldMap = Record<
88+
string,
89+
string | ((data: Record<string, any>) => unknown | Promise<unknown>) | { from: string; map?: AsyncFieldMap; list?: boolean }
90+
>;
91+
92+
/**
93+
* Rocket.Chat -> Apps-Engine transform for a map mixing string renames, (possibly async) derived-value
94+
* functions and nested `{ from, map, list }` descriptors, reproducing the original mapping helper
95+
* exactly:
96+
*
97+
* - the input is deep-cloned up front, so functions may freely mutate the clone (e.g. `delete` fields
98+
* they consume) and the app can never mutate the stored document;
99+
* - string entries copy the source field to the target name when defined and always drop the source
100+
* key from the bucket;
101+
* - function entries receive the clone and set the target only when they return a defined value
102+
* (functions are responsible for deleting any source keys they consume);
103+
* - nested entries recurse when they carry a `map` (producing a `_unmappedProperties_` bucket at each
104+
* level) or copy the cloned source value verbatim when they do not, and `list` entries map arrays
105+
* element-by-element (or wrap a lone value into a single-element array);
106+
* - absent (`undefined`/`null`) nested sources are skipped, so the target stays unset;
107+
* - everything left in the clone becomes `_unmappedProperties_`.
108+
*
109+
* The result shape is caller-asserted via the `Result` type parameter, since it depends on the map's
110+
* function/nested entries in ways that are not worth expressing in the type system.
111+
*
112+
* `dropUnmapped` omits the `_unmappedProperties_` bucket at the root and every nested level, for
113+
* callers (e.g. contacts) that map every attribute individually and must not let extra app data leak
114+
* through. It defaults to `false`, preserving the "bucket the rest" behaviour for other callers.
115+
*/
116+
export async function mappedDecodeAsync<Result = Loose>(
117+
data: Loose,
118+
map: AsyncFieldMap,
119+
options: { dropUnmapped?: boolean } = {},
120+
): Promise<Result> {
121+
const clone: Loose = structuredClone(data);
122+
const result: Loose = {};
123+
124+
for (const [to, from] of Object.entries(map)) {
125+
if (typeof from === 'function') {
126+
const value = await from(clone);
127+
128+
if (typeof value !== 'undefined') {
129+
result[to] = value;
130+
}
131+
} else if (typeof from === 'string') {
132+
if (typeof clone[from] !== 'undefined') {
133+
result[to] = clone[from];
134+
}
135+
136+
delete clone[from];
137+
} else {
138+
const { from: fromName } = from;
139+
140+
if (from.list) {
141+
if (Array.isArray(clone[fromName])) {
142+
if ('map' in from && from.map) {
143+
result[to] = await Promise.all(
144+
clone[fromName].map((item: Loose) => mappedDecodeAsync(item, from.map as AsyncFieldMap, options)),
145+
);
146+
} else {
147+
result[to] = [...clone[fromName]];
148+
}
149+
} else if (clone[fromName] !== undefined && clone[fromName] !== null) {
150+
result[to] = [clone[fromName]];
151+
}
152+
} else if (clone[fromName] !== undefined && clone[fromName] !== null) {
153+
result[to] = from.map
154+
? await mappedDecodeAsync(clone[fromName], from.map as AsyncFieldMap, options)
155+
: structuredClone(clone[fromName]);
156+
}
157+
158+
delete clone[fromName];
159+
}
160+
}
161+
162+
if (!options.dropUnmapped) {
163+
result._unmappedProperties_ = clone;
164+
}
165+
166+
return result as Result;
167+
}
168+
169+
/**
170+
* Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}.
171+
* `decode` produces {@link Decoded}; `encode` is its inverse.
172+
*
173+
* The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving);
174+
* schemas can be tightened later without changing the transform logic.
175+
*/
176+
export function createMappedCodec<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(fieldMap: Map) {
177+
return z.codec(z.custom<Source>(), z.custom<Decoded<Source, Map>>(), {
178+
decode: mappedDecode<Source, Map>(fieldMap),
179+
encode: (app) => mappedEncode<Source, Map>(fieldMap)(app) as Source,
180+
});
181+
}

0 commit comments

Comments
 (0)