Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/meteor/app/apps/server/converters/codecs/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { IAppsLivechatContact } from '@rocket.chat/apps';
import type { ILivechatContact } from '@rocket.chat/core-typings';
import * as z from 'zod';

import { mappedDecodeAsync } from './mappedData';

/**
* Rocket.Chat `ILivechatContact` <-> Apps-Engine `IAppsLivechatContact`.
*
* `decode` (Rocket.Chat -> Apps-Engine) is a straight clone, mirroring the legacy `convertContact`.
* `encode` (Apps-Engine -> Rocket.Chat) maps every attribute individually via a nested field map so
* no extra data coming from the app leaks through — mirroring the legacy `convertAppContact`.
*/
export const ContactCodec = z.codec(z.custom<ILivechatContact>(), z.custom<IAppsLivechatContact>(), {
decode: (contact): IAppsLivechatContact => structuredClone(contact) as unknown as IAppsLivechatContact,
encode: (contact): Promise<ILivechatContact> => {
const map = {
_id: '_id',
_updatedAt: '_updatedAt',
name: 'name',
phones: {
from: 'phones',
list: true,
map: {
phoneNumber: 'phoneNumber',
},
},
emails: {
from: 'emails',
list: true,
map: {
address: 'address',
},
},
contactManager: 'contactManager',
unknown: 'unknown',
conflictingFields: {
from: 'conflictingFields',
list: true,
map: {
field: 'field',
value: 'value',
},
},
customFields: 'customFields',
channels: {
from: 'channels',
list: true,
map: {
name: 'name',
verified: 'verified',
visitor: {
from: 'visitor',
map: {
visitorId: 'visitorId',
source: {
from: 'source',
map: {
type: 'type',
id: 'id',
},
},
},
},
blocked: 'blocked',
field: 'field',
value: 'value',
verifiedAt: 'verifiedAt',
details: {
from: 'details',
map: {
type: 'type',
id: 'id',
alias: 'alias',
label: 'label',
sidebarIcon: 'sidebarIcon',
defaultIcon: 'defaultIcon',
destination: 'destination',
},
},
lastChat: {
from: 'lastChat',
map: {
_id: '_id',
ts: 'ts',
},
},
},
},
createdAt: 'createdAt',
lastChat: {
from: 'lastChat',
map: {
_id: '_id',
ts: 'ts',
},
},
importIds: 'importIds',
};

return mappedDecodeAsync<ILivechatContact>(contact, map, { dropUnmapped: true });
},
});
56 changes: 56 additions & 0 deletions apps/meteor/app/apps/server/converters/codecs/departments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { IAppsDepartment } from '@rocket.chat/apps';
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import * as z from 'zod';

import { mappedDecode } from './mappedData';

/**
* Rocket.Chat `ILivechatDepartment` <-> Apps-Engine `IAppsDepartment`.
*
* `decode` is a plain field-rename with an `_unmappedProperties_` bucket (mirrors the legacy
* `convertDepartment`). `encode` mirrors `convertAppDepartment`: the inverse rename written
* unconditionally, then `_unmappedProperties_` merged on top.
*/
export const DepartmentCodec = z.codec(z.custom<ILivechatDepartment>(), z.custom<IAppsDepartment>(), {
decode: mappedDecode<ILivechatDepartment>({
id: '_id',
name: 'name',
email: 'email',
updatedAt: '_updatedAt',
enabled: 'enabled',
numberOfAgents: 'numAgents',
showOnOfflineForm: 'showOnOfflineForm',
description: 'description',
offlineMessageChannelName: 'offlineMessageChannelName',
requestTagBeforeClosingChat: 'requestTagBeforeClosingChat',
chatClosingTags: 'chatClosingTags',
abandonedRoomsCloseCustomMessage: 'abandonedRoomsCloseCustomMessage',
waitingQueueMessage: 'waitingQueueMessage',
departmentsAllowedToForward: 'departmentsAllowedToForward',
showOnRegistration: 'showOnRegistration',
}) as unknown as (department: ILivechatDepartment) => IAppsDepartment,
encode: (department): ILivechatDepartment => {
const newDepartment = {
_id: department.id,
name: department.name,
email: department.email,
_updatedAt: department.updatedAt,
enabled: department.enabled,
numAgents: department.numberOfAgents,
showOnOfflineForm: department.showOnOfflineForm,
showOnRegistration: department.showOnRegistration,
description: department.description,
offlineMessageChannelName: department.offlineMessageChannelName,
requestTagBeforeClosingChat: department.requestTagBeforeClosingChat,
chatClosingTags: department.chatClosingTags,
abandonedRoomsCloseCustomMessage: department.abandonedRoomsCloseCustomMessage,
waitingQueueMessage: department.waitingQueueMessage,
departmentsAllowedToForward: department.departmentsAllowedToForward,
};

return Object.assign(
newDepartment,
(department as { _unmappedProperties_?: Record<string, unknown> })._unmappedProperties_,
) as unknown as ILivechatDepartment;
},
});
9 changes: 4 additions & 5 deletions apps/meteor/app/apps/server/converters/codecs/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import * as z from 'zod';
* Rocket.Chat `IUser['type']` <-> Apps-Engine `UserType`.
* Mirrors `AppUsersConverter._convertUserTypeToEnum`.
*/
export const UserTypeCodec = z.codec(z.string().optional(), z.string(), {
export const UserTypeCodec = z.codec(z.any(), z.any(), {
Comment thread
d-gubert marked this conversation as resolved.
decode: (type): string => {
switch (type) {
case 'user':
Expand All @@ -36,7 +36,6 @@ export const UserTypeCodec = z.codec(z.string().optional(), z.string(), {
case undefined:
return UserType.UNKNOWN;
default:
// eslint-disable-next-line no-console
console.warn(`A new user type has been added that the Apps don't know about? "${type}"`);
return type.toUpperCase();
}
Expand All @@ -51,7 +50,7 @@ export const UserTypeCodec = z.codec(z.string().optional(), z.string(), {
* `console.warn` (which includes the affected user's id/username) stays in the converter, since
* that context is not available to a standalone codec.
*/
export const UserStatusConnectionCodec = z.codec(z.string().optional(), z.string(), {
export const UserStatusConnectionCodec = z.codec(z.any(), z.any(), {
decode: (status): string => {
switch (status) {
case 'offline':
Expand All @@ -76,7 +75,7 @@ export const UserStatusConnectionCodec = z.codec(z.string().optional(), z.string
* Rocket.Chat `IRoom['t']` <-> Apps-Engine `RoomType`.
* Mirrors `AppRoomsConverter._convertTypeToApp`. Unknown type characters pass through unchanged.
*/
export const RoomTypeCodec = z.codec(z.string(), z.string(), {
export const RoomTypeCodec = z.codec(z.any(), z.any(), {
decode: (typeChar): string => {
switch (typeChar) {
case 'c':
Expand All @@ -99,7 +98,7 @@ export const RoomTypeCodec = z.codec(z.string(), z.string(), {
* Rocket.Chat `ISetting['type']` <-> Apps-Engine `SettingType`.
* Mirrors `AppSettingsConverter._convertTypeToApp`. Unknown types pass through unchanged.
*/
export const SettingTypeCodec = z.codec(z.string(), z.string(), {
export const SettingTypeCodec = z.codec(z.any(), z.any(), {
decode: (type): string => {
switch (type) {
case 'boolean':
Expand Down
8 changes: 8 additions & 0 deletions apps/meteor/app/apps/server/converters/codecs/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
export { RoomTypeCodec, SettingTypeCodec, UserStatusConnectionCodec, UserTypeCodec } from './enums';
export { createMappedCodec, mappedDecode, mappedEncode } from './mappedData';
export type { FieldMap } from './mappedData';
export { SettingCodec } from './settings';
export { RoleCodec } from './roles';
export { VideoConferenceCodec } from './videoConferences';
export { VisitorCodec } from './visitors';
export { DepartmentCodec } from './departments';
export { UserCodec } from './users';
181 changes: 181 additions & 0 deletions apps/meteor/app/apps/server/converters/codecs/mappedData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import * as z from 'zod';

type Loose = Record<string, any>;

/**
* A declarative field map from Apps-Engine (target) property names to Rocket.Chat (source) property
* names. Values are constrained to the keys of `Source`, so mapping a renamed or misspelled source
* field is a compile error.
*/
export type FieldMap<Source> = Record<string, Extract<keyof Source, string>>;

/**
* The Rocket.Chat -> Apps-Engine result of decoding `Source` through `Map`: each target property
* holds its source value (optional, since it is only set when the source value is defined), and
* everything the map did not name is collected under `_unmappedProperties_`.
*/
export type Decoded<Source, Map extends FieldMap<Source>> = {
-readonly [Target in keyof Map]?: Source[Map[Target]];
} & {
_unmappedProperties_: Omit<Source, Map[keyof Map]>;
};

/**
* Rocket.Chat -> Apps-Engine transform for a plain string field map, reproducing the "rename these
* fields, bucket the rest" behaviour of the original mapping helper: the input is deep-cloned (so the
* app can never mutate the stored document), each mapped source field is copied to its target name
* when defined, and every remaining property is collected into `_unmappedProperties_`.
*/
export function mappedDecode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
fieldMap: Map,
): (data: Source) => Decoded<Source, Map> {
const entries = Object.entries(fieldMap) as [string, string][];

return (data: Source): Decoded<Source, Map> => {
const clone: Loose = structuredClone(data);
const result: Loose = {};

for (const [target, sourceKey] of entries) {
if (typeof clone[sourceKey] !== 'undefined') {
result[target] = clone[sourceKey];
}

delete clone[sourceKey];
}

result._unmappedProperties_ = clone;

return result as Decoded<Source, Map>;
};
}

/**
* The Apps-Engine -> Rocket.Chat inverse of {@link mappedDecode}: target fields are copied back to
* their source names when defined and `_unmappedProperties_` is merged onto the result. Because both
* the renamed keys and the bucket originate from `Source`, the result is a `Partial<Source>`.
*
* This is the symmetric counterpart to {@link mappedDecode}; converters whose reverse direction has
* defaults, conditional fields or asymmetric mappings provide their own `encode`.
*/
export function mappedEncode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
fieldMap: Map,
): (app: Decoded<Source, Map>) => Partial<Source> {
const entries = Object.entries(fieldMap) as [string, string][];

return (app: Decoded<Source, Map>): Partial<Source> => {
const { _unmappedProperties_ = {}, ...rest } = app as Loose;
const result: Loose = {};

for (const [target, sourceKey] of entries) {
if (typeof rest[target] !== 'undefined') {
result[sourceKey] = rest[target];
}
}

return { ...result, ..._unmappedProperties_ } as Partial<Source>;
};
}

/**
* A map whose entries are one of the three forms the original mapping helper supports: a source
* property name (string), a function that derives the target value from the (cloned) source data,
* or a nested `{ from, map, list }` descriptor for sub-objects and arrays.
*
* Unlike {@link FieldMap}, this is intentionally loosely typed: its consumers (rooms, messages,
* uploads) map many optional/livechat-only fields that are not part of the base document types.
*/
export type AsyncFieldMap = Record<
string,
string | ((data: Record<string, any>) => unknown | Promise<unknown>) | { from: string; map?: AsyncFieldMap; list?: boolean }
>;

/**
* Rocket.Chat -> Apps-Engine transform for a map mixing string renames, (possibly async) derived-value
* functions and nested `{ from, map, list }` descriptors, reproducing the original mapping helper
* exactly:
*
* - the input is deep-cloned up front, so functions may freely mutate the clone (e.g. `delete` fields
* they consume) and the app can never mutate the stored document;
* - string entries copy the source field to the target name when defined and always drop the source
* key from the bucket;
* - function entries receive the clone and set the target only when they return a defined value
* (functions are responsible for deleting any source keys they consume);
* - nested entries recurse when they carry a `map` (producing a `_unmappedProperties_` bucket at each
* level) or copy the cloned source value verbatim when they do not, and `list` entries map arrays
* element-by-element (or wrap a lone value into a single-element array);
* - absent (`undefined`/`null`) nested sources are skipped, so the target stays unset;
* - everything left in the clone becomes `_unmappedProperties_`.
*
* The result shape is caller-asserted via the `Result` type parameter, since it depends on the map's
* function/nested entries in ways that are not worth expressing in the type system.
*
* `dropUnmapped` omits the `_unmappedProperties_` bucket at the root and every nested level, for
* callers (e.g. contacts) that map every attribute individually and must not let extra app data leak
* through. It defaults to `false`, preserving the "bucket the rest" behaviour for other callers.
*/
export async function mappedDecodeAsync<Result = Loose>(
data: Loose,
map: AsyncFieldMap,
options: { dropUnmapped?: boolean } = {},
): Promise<Result> {
const clone: Loose = structuredClone(data);
const result: Loose = {};

for (const [to, from] of Object.entries(map)) {
if (typeof from === 'function') {
const value = await from(clone);

if (typeof value !== 'undefined') {
result[to] = value;
}
} else if (typeof from === 'string') {
if (typeof clone[from] !== 'undefined') {
result[to] = clone[from];
}

delete clone[from];
} else {
const { from: fromName } = from;

if (from.list) {
if (Array.isArray(clone[fromName])) {
if ('map' in from && from.map) {
result[to] = await Promise.all(
clone[fromName].map((item: Loose) => mappedDecodeAsync(item, from.map as AsyncFieldMap, options)),
);
} else {
result[to] = [...clone[fromName]];
}
} else if (clone[fromName] !== undefined && clone[fromName] !== null) {
result[to] = [clone[fromName]];
}
} else if (clone[fromName] !== undefined && clone[fromName] !== null) {
result[to] = from.map
? await mappedDecodeAsync(clone[fromName], from.map as AsyncFieldMap, options)
: structuredClone(clone[fromName]);
}

delete clone[fromName];
}
}

if (!options.dropUnmapped) {
result._unmappedProperties_ = clone;
}

return result as Result;
}

/**
* Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}.
* `decode` produces {@link Decoded}; `encode` is its inverse.
*
* The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving);
* schemas can be tightened later without changing the transform logic.
*/
export function createMappedCodec<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(fieldMap: Map) {
return z.codec(z.custom<Source>(), z.custom<Decoded<Source, Map>>(), {
decode: mappedDecode<Source, Map>(fieldMap),
encode: (app) => mappedEncode<Source, Map>(fieldMap)(app) as Source,
});
}
Loading
Loading