Skip to content
Merged
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
43 changes: 3 additions & 40 deletions src/lib/content/content-field-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AssetReferenceExtractor } from "../assets/asset-reference-extractor";
import * as mgmtApi from "@agility/management-sdk";
import { ContentItemMapper } from "lib/mappers/content-item-mapper";
import { AssetMapper } from "lib/mappers/asset-mapper";
import { getLinkedContentCompanionFields, findFieldKey } from "./linked-content-companion-fields";

export function createContentFieldMapper() {
return new ContentFieldMapper();
Expand Down Expand Up @@ -73,12 +74,12 @@ export class ContentFieldMapper {
// "linkedContentId", HotAndColdNumbersSection's "LinkedContentValue") don't. The model schema is
// the only reliable source for the real name, so remap by reading it from context.model instead
// of guessing a suffix.
for (const [mainFieldName, valueFieldName] of this.getContentDropdownValueFieldNames(context)) {
for (const [mainFieldName, valueFieldName] of getLinkedContentCompanionFields(context?.model)) {
// Self-pointing setting (e.g. GameBanner's own field name): nothing separate to remap —
// the mainFieldName pass above (or the sortids/contentid handling) already covers it.
if (valueFieldName.toLowerCase() === mainFieldName.toLowerCase()) continue;

const valueFieldKey = this.findFieldKey(fields, valueFieldName);
const valueFieldKey = findFieldKey(fields, valueFieldName);
if (!valueFieldKey) continue; // e.g. a sentinel setting like "CREATENEW" that names no real field

const rawValue = fields[valueFieldKey];
Expand Down Expand Up @@ -313,44 +314,6 @@ export class ContentFieldMapper {
return { mappedValue, warnings, errors };
}

/**
* PROD-2431/PROD-2435: read [mainFieldName, companionFieldName] pairs off the model schema's
* Content-typed fields. `settings.LinkeContentDropdownValueField` (Agility's own spelling) names
* the field that actually carries the raw content ID(s) for a linked-content dropdown. Only
* non-empty settings are returned; the caller still has to handle the value naming a field that
* doesn't exist in the payload (a sentinel like "CREATENEW") or naming itself (no separate
* companion to remap).
*/
private getContentDropdownValueFieldNames(context?: ContentFieldMappingContext): Array<[string, string]> {
const modelFields = context?.model?.fields;
if (!Array.isArray(modelFields)) return [];

const pairs: Array<[string, string]> = [];
for (const field of modelFields) {
// PROD-2442: a "grid" (full-list) Linked Content field's companion selection column is named
// by SortIDFieldName instead of LinkeContentDropdownValueField — the same per-field, no-fixed-
// convention naming problem PROD-2431/2435 already solved for dropdown/checkbox, just under a
// different setting. Fall back to it so that companion also gets remapped.
const valueFieldName: string | undefined =
field?.settings?.LinkeContentDropdownValueField || field?.settings?.SortIDFieldName;
if (field?.name && valueFieldName) {
pairs.push([field.name, valueFieldName]);
}
}
return pairs;
}

/**
* Look up a field by name in a fields object, case-insensitively — the model schema's field
* name and the payload's actual field key can differ in casing (e.g. schema "LinkedContentValue"
* vs. a payload key camelCased to "linkedContentValue").
*/
private findFieldKey(fields: any, fieldName: string): string | undefined {
if (fieldName in fields) return fieldName;
const lowerTarget = fieldName.toLowerCase();
return Object.keys(fields).find((key) => key.toLowerCase() === lowerTarget);
}

/**
* PROD-2431: shared remap for a comma-separated list of SOURCE content IDs, used both for a
* content-reference field's "sortids" and for a linked-content dropdown's companion selection
Expand Down
43 changes: 43 additions & 0 deletions src/lib/content/linked-content-companion-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as mgmtApi from "@agility/management-sdk";

/**
* A Content-typed dropdown/checkbox/searchlistbox field's actual selection, and a full-list
* "grid" field's custom sort order, live in a companion field separate from the one the designer
* sees — named by that field's own model setting (`LinkeContentDropdownValueField` or
* `SortIDFieldName` respectively). Neither has a fixed naming convention (PROD-2431/2435/2442
* sampled a real target schema: only 4 of 14 dropdown fields used the `<field>_ValueField`
* suffix), so the model schema is the only reliable source for the real name.
*
* Shared by ContentFieldMapper (which remaps the companion's value) and the content-pusher
* utilities that scan a content item's fields for references (which need to recognize the
* companion as carrying a reference at all) — every consumer of this pattern should read it from
* here rather than re-deriving its own naming heuristic.
*/
export function getLinkedContentCompanionFields(
model?: mgmtApi.Model | { fields?: mgmtApi.ModelField[] | any[] } | null
): Array<[mainFieldName: string, companionFieldName: string]> {
const modelFields = model?.fields;
if (!Array.isArray(modelFields)) return [];

const pairs: Array<[string, string]> = [];
for (const field of modelFields) {
const companionFieldName: string | undefined =
field?.settings?.LinkeContentDropdownValueField || field?.settings?.SortIDFieldName;
if (field?.name && companionFieldName) {
pairs.push([field.name, companionFieldName]);
}
}
return pairs;
}

/**
* Look up a field by name in a fields object, case-insensitively — the model schema's field name
* and the payload's actual field key can differ in casing (e.g. schema "LinkedContentValue" vs. a
* payload key camelCased to "linkedContentValue").
*/
export function findFieldKey(fields: any, fieldName: string): string | undefined {
if (!fields || typeof fields !== "object") return undefined;
if (fieldName in fields) return fieldName;
const lowerTarget = fieldName.toLowerCase();
return Object.keys(fields).find((key) => key.toLowerCase() === lowerTarget);
}
6 changes: 5 additions & 1 deletion src/lib/pushers/content-pusher/content-batch-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,11 @@ export class ContentBatchProcessor {
// precise, actionable reason instead of shipping a payload that is guaranteed to fail.
const unresolvedRefs = collectUnresolvedContentReferences(
contentItem.fields || {},
this.config.referenceMapper
this.config.referenceMapper,
"",
// PROD-2446: sourceModel carries each field's LinkeContentDropdownValueField/SortIDFieldName
// setting, needed to recognize an unresolved reference living only in a companion field.
sourceModel
);
if (unresolvedRefs.length > 0) {
const detail = unresolvedRefs
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getLinkedContentCompanionFields, findFieldKey } from "lib/content/linked-content-companion-fields";

/**
* Recursively walks content item fields to find SINGLE-ITEM content references —
* `contentid`/`contentID` values and comma-separated `sortids` — and returns the
Expand All @@ -12,8 +14,14 @@
* Only positive IDs are returned; 0 / -1 mean "no reference selected" and are ignored so
* we don't promote items with intentionally-empty linked-content fields. This matches the
* `> 0` guard used by collectUnresolvedContentReferences.
*
* PROD-2446: a Content-typed dropdown/checkbox/grid field's reference can live ONLY in a
* companion field (named by the model's LinkeContentDropdownValueField/SortIDFieldName setting —
* see PROD-2431/2435/2442), invisible to the structural walk above since it's a bare string under
* an arbitrary key. Pass `model` so an item that depends on another item solely through such a
* companion field is still recognized as a dependency and promoted to push first.
*/
export function collectContentIDReferences(fields: any): number[] {
export function collectContentIDReferences(fields: any, model?: any): number[] {
const found: number[] = [];

function walk(node: any): void {
Expand Down Expand Up @@ -50,5 +58,20 @@ export function collectContentIDReferences(fields: any): number[] {
}

walk(fields);

// Companion fields are top-level siblings of the main field, not nested inside it.
for (const [, companionFieldName] of getLinkedContentCompanionFields(model)) {
const companionKey = findFieldKey(fields, companionFieldName);
if (!companionKey) continue;

const companionValue = fields[companionKey];
if (typeof companionValue !== "string" || !companionValue.trim()) continue;

for (const part of companionValue.split(",")) {
const id = parseInt(part.trim());
if (!isNaN(id) && id > 0) found.push(id);
}
}

return found;
}
28 changes: 22 additions & 6 deletions src/lib/pushers/content-pusher/util/get-content-item-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function getContentItemTypes(

// Find every item this one depends on — whole-list references (by referenceName)
// AND single-item references (by contentID) — and mark them linked (pushed first).
const referencedIds = collectReferencedContentIDs(item, itemsByReferenceName, allItemsById);
const referencedIds = collectReferencedContentIDs(item, itemsByReferenceName, allItemsById, modelMapper);
if (referencedIds.length > 0) {
markReferencedItems(
referencedIds,
Expand Down Expand Up @@ -105,15 +105,18 @@ function buildItemMaps(contentItems: ContentItem[]): {
* both reference kinds:
* - whole-list references (referencename + fulllist:true) → every item sharing that
* referenceName (the full list), looked up via itemsByReferenceName;
* - single-item references (contentid / sortids) → the specific referenced item, only
* when it is present in the current content set (allItemsById).
* - single-item references (contentid / sortids, INCLUDING one living only in a
* LinkeContentDropdownValueField/SortIDFieldName companion field — PROD-2446) → the
* specific referenced item, only when it is present in the current content set
* (allItemsById).
*
* Returns the referenced items' contentIDs; the referencing item itself is never included.
*/
function collectReferencedContentIDs(
item: ContentItem,
itemsByReferenceName: Map<string, ContentItem[]>,
allItemsById: Map<number, ContentItem>
allItemsById: Map<number, ContentItem>,
modelMapper: ModelMapper
): number[] {
const ids: number[] = [];

Expand All @@ -125,7 +128,8 @@ function collectReferencedContentIDs(
}

// Single-item references → the specific referenced item, if it is in this push set
for (const contentID of collectContentIDReferences(item.fields || {})) {
const sourceModel = getSourceModel(item, modelMapper);
for (const contentID of collectContentIDReferences(item.fields || {}, sourceModel)) {
if (allItemsById.has(contentID)) {
ids.push(contentID);
}
Expand All @@ -134,6 +138,18 @@ function collectReferencedContentIDs(
return ids;
}

/**
* Resolves an item's own source model (by its definitionName), so a Content-typed field's
* companion-field name (LinkeContentDropdownValueField/SortIDFieldName) can be read off it.
* Returns null if unmapped — collectContentIDReferences degrades gracefully to the structural-only
* walk in that case, same as before PROD-2446.
*/
function getSourceModel(item: ContentItem, modelMapper: ModelMapper): any {
if (!item.properties?.definitionName) return null;
const modelMapping = modelMapper.getModelMappingByReferenceName(item.properties.definitionName.toLowerCase(), "source");
return modelMapping ? modelMapper.getMappedEntity(modelMapping, "source") : null;
}

/**
* Recursively marks all items referenced (transitively) by the given contentIDs as linked,
* so they are pushed before the items that reference them. Uses a stack-based approach with
Expand Down Expand Up @@ -170,7 +186,7 @@ function markReferencedItems(
normalSet.delete(contentID); // Remove from normal if it was added there

// Recursively process this item's own dependency targets
for (const nestedId of collectReferencedContentIDs(item, itemsByReferenceName, allItemsById)) {
for (const nestedId of collectReferencedContentIDs(item, itemsByReferenceName, allItemsById, modelMapper)) {
if (!visited.has(nestedId)) {
stack.push(nestedId);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ContentItemMapper } from "lib/mappers/content-item-mapper";
import { getLinkedContentCompanionFields, findFieldKey } from "lib/content/linked-content-companion-fields";

/**
* Recursively check for unresolved content references
Expand Down Expand Up @@ -59,11 +60,19 @@ export interface UnresolvedContentReference {
* whole structure so callers can report exactly which field/reference is unmapped. Only
* positive IDs are considered — 0 / -1 mean "no reference selected" and are ignored so we
* don't over-skip items with intentionally empty linked-content fields.
*
* PROD-2446: a Content-typed dropdown/checkbox/grid field's reference can live ONLY in a
* companion field (named by the model's LinkeContentDropdownValueField/SortIDFieldName setting —
* see PROD-2431/2435/2442), invisible to the structural contentid/sortids walk above since it's a
* bare string under an arbitrary key. Pass `model` so this guard can recognize those too — without
* it, an unresolved companion-field reference ships to the target undetected, reproducing the same
* server-side NullReferenceException this guard exists to prevent (PROD-2309).
*/
export function collectUnresolvedContentReferences(
obj: any,
referenceMapper: ContentItemMapper,
path = ""
path = "",
model?: any
): UnresolvedContentReference[] {
const results: UnresolvedContentReference[] = [];
if (typeof obj !== "object" || obj === null) {
Expand All @@ -72,7 +81,7 @@ export function collectUnresolvedContentReferences(

if (Array.isArray(obj)) {
obj.forEach((item, index) => {
results.push(...collectUnresolvedContentReferences(item, referenceMapper, `${path}[${index}]`));
results.push(...collectUnresolvedContentReferences(item, referenceMapper, `${path}[${index}]`, model));
});
return results;
}
Expand Down Expand Up @@ -100,7 +109,27 @@ export function collectUnresolvedContentReferences(
}

// Recurse into nested objects/arrays
results.push(...collectUnresolvedContentReferences(value, referenceMapper, childPath));
results.push(...collectUnresolvedContentReferences(value, referenceMapper, childPath, model));
}

// PROD-2446: companion fields are top-level siblings of the main field, not nested inside it —
// only check them at the top of the walk (path === ""), not on every recursive call, so a
// companion field's own value never gets misread as if it belonged to some nested object.
if (path === "") {
for (const [, companionFieldName] of getLinkedContentCompanionFields(model)) {
const companionKey = findFieldKey(obj, companionFieldName);
if (!companionKey) continue;

const companionValue = obj[companionKey];
if (typeof companionValue !== "string" || !companionValue.trim()) continue;

for (const idStr of companionValue.split(",")) {
const contentId = parseInt(idStr.trim());
if (!isNaN(contentId) && contentId > 0 && !referenceMapper.getContentItemMappingByContentID(contentId, "source")) {
results.push({ path: companionKey, contentID: contentId });
}
}
}
}

return results;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,36 @@ describe("collectContentIDReferences", () => {
// Whole-list refs are handled by collectListReferenceNames, not here.
expect(collectContentIDReferences({ items: { referencename: "somelist", fulllist: true } })).toEqual([]);
});

// PROD-2446: a reference that lives ONLY in a LinkeContentDropdownValueField/SortIDFieldName
// companion field (PROD-2431/2435/2442) is a bare string under an arbitrary key — invisible to
// the structural walk above without the model to name it.
describe("companion-field references (schema-driven, PROD-2446)", () => {
it("collects an id living only in an arbitrarily-named companion field", () => {
const model = {
fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }],
};
const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" };
expect(collectContentIDReferences(fields, model)).toEqual([11875]);
});

it("collects comma-separated ids from a grid field's SortIDFieldName companion", () => {
const model = {
fields: [{ name: "sharedGridSorted", settings: { SortIDFieldName: "sharedGridSorted_SortField" } }],
};
const fields = { sharedGridSorted: { referencename: "list", fulllist: true }, sharedGridSorted_SortField: "3,7,11" };
expect(collectContentIDReferences(fields, model).sort((a, b) => a - b)).toEqual([3, 7, 11]);
});

it("is a no-op when no model is supplied (back-compat)", () => {
const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" };
expect(collectContentIDReferences(fields)).toEqual([]);
});

it("tolerates a sentinel setting that names no real field", () => {
const model = { fields: [{ name: "posts", settings: { LinkeContentDropdownValueField: "CREATENEW" } }] };
const fields = { posts: { referencename: "posts-list", fulllist: true } };
expect(collectContentIDReferences(fields, model)).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,32 @@ describe("getContentItemTypes — single-item content references", () => {
expect(linkedIds).toContain(mid.contentID);
expect(linkedIds).toContain(deep.contentID);
});

// PROD-2446: a reference living only in a LinkeContentDropdownValueField/SortIDFieldName
// companion field (PROD-2431/2435/2442) is invisible to the structural contentid/sortids walk —
// without the referencing item's own model, the referenced item was never promoted to push-first.
it("promotes an item referenced only through a companion field (PROD-2446)", () => {
const target = makeItem("drawgamesassets", "DrawGameAssetsSchema");
const playslip = makeItem("playslipsection", "PlaylipSection", {
// main field holds the container reference name, per the LinkedContentDropdown contract —
// the actual selected id lives only in the companion field
linkedDrawGameAsset: "drawgamesassets",
linkedContentId: `${target.contentID}`,
});

const opts = makeValidOpts();
opts.modelMapper.getMappedEntity = jest.fn().mockReturnValue({
id: 10,
fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }],
});

const result = getContentItemTypes([playslip, target], opts);

expect(result.linkedContentItems).toHaveLength(1);
expect(result.linkedContentItems[0]).toBe(target);
expect(result.normalContentItems).toHaveLength(1);
expect(result.normalContentItems[0]).toBe(playslip);
});
});

// ─── reference to unknown item ────────────────────────────────────────────────
Expand Down
Loading
Loading