-
Notifications
You must be signed in to change notification settings - Fork 13.7k
chore: refactor apps converters to TypeScript with Zod codecs (2/2) #41205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
d-gubert
wants to merge
12
commits into
develop
Choose a base branch
from
claude/converters-zod-migration-p29du9
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
bc2b3c1
refactor(apps): Phase 1 — migrate settings, roles, videoConferences t…
claude dbc920f
refactor(apps): Phase 2 — migrate visitors, departments, users to Zod…
claude 5c7e0de
fix(apps): make enum codecs lenient to preserve converter behaviour
claude 4922213
fix(apps): correct overload selection in converters edge-case tests
claude a6479d2
refactor(apps): Phase 3 — migrate uploads and rooms to Zod codecs
claude 4b98121
refactor(apps): Phase 4 — migrate messages, threads and contacts off …
claude 458c0b9
refactor(apps): Phase 5 — remove transformMappedData
claude a955c61
refactor(apps): make the mapped-codec helpers generic and type-safe
claude e856cca
fix(apps): address codec-converter PR review feedback
claude d629036
fix(apps): guard optional relationship fields in room/upload encode
claude 9c93d92
fix(apps): restore legacy bucket behaviour and guard map-less descrip…
claude db22f22
fix(apps): address PR review on upload path and settings inverse
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
103 changes: 103 additions & 0 deletions
103
apps/meteor/app/apps/server/converters/codecs/contacts.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
56
apps/meteor/app/apps/server/converters/codecs/departments.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
181
apps/meteor/app/apps/server/converters/codecs/mappedData.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.