diff --git a/genstudio-mlr-claims-app/README.md b/genstudio-mlr-claims-app/README.md index 2d2934f..5eaacdb 100644 --- a/genstudio-mlr-claims-app/README.md +++ b/genstudio-mlr-claims-app/README.md @@ -32,6 +32,23 @@ aio app deploy # Deploy to production - ✅ **`utils/claimsValidation.ts`** - Claims checking algorithms - ⚠️ **`app/`** - DO NOT modify (core registration logic) +## Claims Data Sources + +This app supports multiple claims data sources: + +### Local Claims Provider (Default) +Uses static claims data from `src/genstudiopem/actions/claims/provider/local/claims.js`. Perfect for development and testing. + +### AEM Content Fragment Claims Provider (New!) +Fetches claims dynamically from AEM Content Fragments. Ideal for production content management workflows. + + +#### Edit .env with your AEM instance details and provider flag + + AEM_HOST= # author-pxxxx-exxxx.adobeaemcloud.com + CF_FOLDER_PATH= # e.g. /content/dam/us/en/claims + CLAIM_PROVIDER_TYPE= # ## this is required filed with AEM_HOST configuration + ## Migration Note This app has been updated to use: diff --git a/genstudio-mlr-claims-app/app.config.yaml b/genstudio-mlr-claims-app/app.config.yaml index 2eb74fa..d17979d 100644 --- a/genstudio-mlr-claims-app/app.config.yaml +++ b/genstudio-mlr-claims-app/app.config.yaml @@ -13,6 +13,9 @@ extensions: inputs: LOG_LEVEL: info actionType: getClaims + aemHost: $AEM_HOST + cfFolderPath: $CF_FOLDER_PATH + providerType: $CLAIM_PROVIDER_TYPE annotations: require-adobe-auth: true final: false diff --git a/genstudio-mlr-claims-app/package.json b/genstudio-mlr-claims-app/package.json index cc70af4..f1bae97 100644 --- a/genstudio-mlr-claims-app/package.json +++ b/genstudio-mlr-claims-app/package.json @@ -8,6 +8,7 @@ "@adobe/aio-sdk": "^6.0.0", "@react-spectrum/s2": "^0.9.0", "core-js": "^3.6.4", + "node-fetch": "^2.6.0", "number-to-words": "^1.2.4", "p-retry": "^6.2.1", "react": "^18.3.1", diff --git a/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/index.js b/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/index.js index 0597a3c..b94c42a 100644 --- a/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/index.js +++ b/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/index.js @@ -13,12 +13,25 @@ governing permissions and limitations under the License. const { Core } = require("@adobe/aio-sdk"); const { errorResponse, ValidationError } = require("../utils"); const LocalClaimProvider = require("./provider/local/LocalClaimProvider"); +const AEMClaimProvider = require("./provider/aem/AEMClaimProvider"); +const AEM = "aem"; +const LOCAL = "local"; exports.main = async (params) => { const logger = Core.Logger("main", { level: params.LOG_LEVEL || "info" }); const actionType = params.actionType || "getClaims"; - const claimProvider = new LocalClaimProvider(params, logger); + + // Choose provider based on parameters + logger.debug("Action parameters:", JSON.stringify(params, null, 2)); + logger.debug("AEM host parameter:", params.aemHost); + logger.debug("Provider type parameter:", params.providerType); + + const providerType = params.providerType || (params.providerType === AEM && params.aemHost ? AEM : LOCAL); + const claimProvider = providerType === AEM + ? new AEMClaimProvider(params, logger) + : new LocalClaimProvider(params, logger); + try { switch (actionType) { case "getClaims": diff --git a/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/provider/aem/AEMClaimProvider.js b/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/provider/aem/AEMClaimProvider.js new file mode 100644 index 0000000..93a5d8f --- /dev/null +++ b/genstudio-mlr-claims-app/src/genstudiopem/actions/claims/provider/aem/AEMClaimProvider.js @@ -0,0 +1,113 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const ClaimProvider = require("../ClaimProvider"); +const { ValidationError, getBearerToken } = require("../../../utils"); +const fetch = require("node-fetch"); + +/** + * @class AEMClaimProvider + * @description + * AEM Content Fragment implementation of ClaimProvider that fetches raw content fragments from AEM. + * Claims extraction and transformation logic is handled client-side in React components. + */ +class AEMClaimProvider extends ClaimProvider { + constructor(params, logger) { + super(params, logger); + this.aemHost = params.aemHost; + this.cfFolderPath = params.cfFolderPath || ""; + } + + /** + * Gets raw content fragments from AEM. + * @param {Object} params + * @param {string} params.aemHost - AEM host URL + * @param {string} [params.cfFolderPath] - Content fragment folder path + * @returns {Promise<{statusCode: number, body: {fragments: Array}}>} + */ + async getClaims(params) { + try { + this.validateParams(params); + + // Fetch content fragments from AEM + const fragments = await this.fetchContentFragments(params); + + return { + statusCode: 200, + body: { fragments: fragments || [] }, + }; + } catch (error) { + this.logger.error("Error fetching fragments from AEM:", error); + if (error instanceof ValidationError) { + throw error; + } + throw new Error(`Failed to fetch fragments from AEM: ${error.message}`); + } + } + + /** + * Validates required parameters for AEM operations. + * @param {Object} params + * @throws {ValidationError} + */ + validateParams(params) { + if (!params.aemHost) { + throw new ValidationError("aemHost parameter is required"); + } + + if (!params.__ow_headers?.authorization) { + throw new ValidationError("Authorization header is required"); + } + } + + /** + * Fetches content fragments from AEM. + * @param {Object} params + * @returns {Promise} + */ + async fetchContentFragments(params) { + const imsToken = getBearerToken(params); + const authHeaders = { + Authorization: `Bearer ${imsToken}`, + "Content-Type": "application/json", + }; + + let url = `https://${params.aemHost}/adobe/sites/cf/fragments`; + if (this.cfFolderPath.length > 0) { + url = url + "?path=" + this.cfFolderPath; + } + + try { + const response = await fetch(url, { + method: "GET", + headers: authHeaders, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`AEM API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + if(response && typeof response === "object") { + const data = await response.json(); + return data.items; + } else { + this.logger.warn("Received no fragments from AEM or unexpected response format"); + return []; + } + } catch (error) { + this.logger.error("Error during AEM API request:", error.message); + throw new Error(`Failed to fetch content fragments from AEM: ${error.message}`); + } + } +} + +module.exports = AEMClaimProvider; \ No newline at end of file diff --git a/genstudio-mlr-claims-app/src/genstudiopem/actions/utils.js b/genstudio-mlr-claims-app/src/genstudiopem/actions/utils.js index 759d782..3d88967 100644 --- a/genstudio-mlr-claims-app/src/genstudiopem/actions/utils.js +++ b/genstudio-mlr-claims-app/src/genstudiopem/actions/utils.js @@ -36,8 +36,28 @@ function errorResponse(statusCode, message, logger) { }, }; } +/** + * + * Extracts the bearer token string from the Authorization header in the request parameters. + * + * @param {object} params action input parameters. + * + * @returns {string|undefined} the token string or undefined if not set in request headers. + * + */ +function getBearerToken(params) { + if ( + params.__ow_headers && + params.__ow_headers.authorization && + params.__ow_headers.authorization.startsWith("Bearer ") + ) { + return params.__ow_headers.authorization.substring("Bearer ".length); + } + return undefined; +} module.exports = { ValidationError, errorResponse, + getBearerToken, }; diff --git a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/Constants.ts b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/Constants.ts index e631f2d..839ac2d 100644 --- a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/Constants.ts +++ b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/Constants.ts @@ -43,6 +43,8 @@ export const APP_METADATA: AppMetadata = { }; export const GET_CLAIMS_ACTION = "genstudio-mlr-claims-app/get-claims"; +export const CF_DISCRIPTION_FIELD = "textAssetMatchText"; +export const CLAIM_PROVIDER_TYPE = process.env.CLAIM_PROVIDER_TYPE; export const VIOLATION_STATUS = { Valid: "valid", diff --git a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsLibraryPicker.tsx b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsLibraryPicker.tsx index dd6bd0f..888b0b1 100644 --- a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsLibraryPicker.tsx +++ b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsLibraryPicker.tsx @@ -10,30 +10,107 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { Picker, PickerItem } from "@react-spectrum/s2"; +import { Picker, PickerItem, Text, ProgressCircle } from "@react-spectrum/s2"; import React, { Key } from "react"; import { ClaimLibrary } from "../../types"; interface ClaimsLibraryPickerProps { // eslint-disable-next-line handleSelectionChange: (key: Key | null) => void; - claimLibraries: ClaimLibrary[]; + claimLibraries: any; selectedKey?: string; + isLoading?: boolean; + error?: string | null; + hasAttemptedFetch?: boolean; } +const renderWaitingForFragments = () => { + return ( +
+ + Waiting for fragments to be ready... +
+ ); + }; + const renderNoFragments = () => { + return ( +
+ No AEM fragment present or something went wrong. +
+ ); + }; export const ClaimsLibraryPicker: React.FC = ({ handleSelectionChange, claimLibraries, selectedKey, + isLoading = false, + error = null, + hasAttemptedFetch = false, }) => { + + // Show loading state when fetching claims + if (isLoading) { + return renderWaitingForFragments(); + } + + // Show error message if there's an error + if (error && hasAttemptedFetch) { + return ( +
+ {error} +
+ ); + } + + // Show no fragments message when no claims available + if (hasAttemptedFetch && (!claimLibraries || claimLibraries.length === 0)) { + return renderNoFragments(); + } + + // Don't render anything if we haven't tried fetching yet + if (!hasAttemptedFetch) { + return null; + } + + // Show the picker when we have claim libraries return ( - {claimLibraries?.map((library: ClaimLibrary) => ( - + {claimLibraries?.map((library: ClaimLibrary, index: number) => ( + {library.name} ))} diff --git a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsProcessor.ts b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsProcessor.ts new file mode 100644 index 0000000..2945da1 --- /dev/null +++ b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/ClaimsProcessor.ts @@ -0,0 +1,205 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * @fileoverview Client-side claims processor for AEM Content Fragments. + * This module handles validation and transformation of raw AEM fragments into claims format. + */ +import { CF_DISCRIPTION_FIELD } from "../../Constants"; +export interface AEMFragment { + id: string; + title?: string; + name?: string; + fields?: FragmentField[]; +} + +export interface FragmentField { + name: string; + values: string[]; +} + +export interface ClaimItem { + id: string; + description: string; + isError?: boolean; +} + +export interface ClaimCategory { + id: string; + name: string; + claims: ClaimItem[]; +} + +/** + * Validates that a fragment has the required structure for claims extraction. + */ +export function isValidClaimFragment(fragment: AEMFragment): boolean { + if (!fragment || !fragment.id) { + return false; + } + + const hasTitle = fragment.title || fragment.name; + + // Check if fields array exists and CF_DISCRIPTION_FIELD field exists + if (!fragment.fields || !Array.isArray(fragment.fields)) { + return false; + } + + const claimsField = fragment.fields.find(field => field.name === CF_DISCRIPTION_FIELD); + + const isValid = !!(hasTitle && claimsField); + return isValid; +} + +/** + * Filters fragments to only include those suitable for claims processing. + */ +export function filterValidClaimFragments(fragments: AEMFragment[]): AEMFragment[] { + if (!Array.isArray(fragments)) { + return []; + } + //Return no fragments + if (fragments.length === 0) { + return []; + } + + return fragments; +} + +/** + * Extracts claim information from a content fragment. + */ +export function extractClaimFromFragment(fragment: AEMFragment): { title: string; claims: ClaimItem[] } | null { + try { + // Extract fragment title/name to use as category + const title = fragment.title || fragment.name || `Fragment-${fragment.id}`; + // Find the CF_DISCRIPTION_FIELD field in the fields array + const claimsField = fragment.fields.find( + field => field.name === CF_DISCRIPTION_FIELD + ); + const claimsInFragment = claimsField + ? claimsField.values.map((claimText, index) => ({ + id: `${fragment.id}-claim-${index}`, + description: claimText.replace(/<[^>]*>/g, ""), + })) + : []; + if (claimsInFragment.length === 0) { + return { + title: title, + claims: [{ + id: `${fragment.id}-no-claims`, + description: "No claim present in this Fragment", + isError: true + }] + }; + } + + return { + title: title, + claims: claimsInFragment + }; + } catch (error) { + // Return error indicator instead of null + const title = fragment?.title || fragment?.name || `Fragment-${fragment?.id || 'unknown'}`; + return { + title: title, + claims: [{ + id: `${fragment?.id || 'unknown'}-error`, + description: "Error processing this CF", + isError: true + }] + }; + } +} + +/** + * Generates a URL-safe category ID from the fragment title. + */ +export function generateCategoryId(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Transforms AEM content fragments into claims format. + * Each content fragment becomes a category with its CF_DISCRIPTION_FIELD as claims. + */ +export function transformFragmentsToClaims( + fragments: AEMFragment[], + libraryId?: string +): ClaimCategory[] | { claims: ClaimItem[] } { + const claimLibraries: ClaimCategory[] = []; + + fragments.forEach((fragment) => { + try { + const claimData = extractClaimFromFragment(fragment); + + if (!claimData) { + return; + } + + // Use fragment name/title as category + const categoryId = generateCategoryId(claimData.title); + + // Each content fragment becomes its own category + const claimCategory: ClaimCategory = { + id: categoryId, + name: claimData.title, + claims: claimData.claims || [] + }; + claimLibraries.push(claimCategory); + + } catch (error) { + console.error(`Error processing fragment ${fragment.id}:`, error); + } + }); + + // If filtering by libraryId, return just the claims for that library + if (libraryId) { + const library = claimLibraries.find(lib => lib.id === libraryId); + const result = library ? { claims: library.claims } : { claims: [] }; + return result; + } + return claimLibraries; +} + +/** + * Processes raw AEM fragments into claims with validation and error handling. + */ +export function processAEMFragments( + rawFragments: AEMFragment[], + libraryId?: string +): { claimLibraries: ClaimCategory[] | { claims: ClaimItem[] }, hasValidFragments: boolean, totalFragments: number } { + + + if (!rawFragments || rawFragments.length === 0) { + return { + claimLibraries: libraryId ? { claims: [] } : [], + hasValidFragments: false, + totalFragments: 0 + }; + } + + // Filter to valid claim fragments + const validFragments = filterValidClaimFragments(rawFragments); + + // Transform to claims format + const claimLibraries = transformFragmentsToClaims(validFragments, libraryId); + + return { + claimLibraries, + hasValidFragments: validFragments.length > 0, + totalFragments: rawFragments.length + }; +} \ No newline at end of file diff --git a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/index.tsx b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/index.tsx index bb0912b..59e0945 100644 --- a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/index.tsx +++ b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/components/PromptDialog/index.tsx @@ -30,7 +30,7 @@ export default function PromptDialog(): React.JSX.Element { const guestConnection = useGuestConnection(EXTENSION_ID); const auth = useAuth(guestConnection); - const { claimLibraries } = useClaimActions(auth); + const { claimLibraries, isLoadingClaims, error, hasAttemptedFetch } = useClaimActions(auth); // ========================================================== // EFFECTS & HOOKS // ========================================================== @@ -163,6 +163,9 @@ export default function PromptDialog(): React.JSX.Element { handleSelectionChange={handleClaimsLibrarySelection} claimLibraries={claimLibraries} selectedKey={selectedClaimLibraryId} + isLoading={isLoadingClaims} + error={error} + hasAttemptedFetch={hasAttemptedFetch} />
diff --git a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/hooks/useClaim.ts b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/hooks/useClaim.ts index f45bb3d..2b3ab94 100644 --- a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/hooks/useClaim.ts +++ b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/hooks/useClaim.ts @@ -13,20 +13,24 @@ governing permissions and limitations under the License. import { useState, useCallback, useEffect } from "react"; import { actionWebInvoke } from "../utils/actionWebInvoke"; import actions from "../config.json"; -import { GET_CLAIMS_ACTION } from "../Constants"; +import { GET_CLAIMS_ACTION, CLAIM_PROVIDER_TYPE } from "../Constants"; import { Auth, ClaimLibrary } from "../types"; +import { processAEMResponse, processLocalResponse } from "../utils/responseProcessors"; /** - * Hook to fetch claims from the backend action + * Hook to fetch claims from the backend action (supports both AEM and Local providers) * @param auth - The authentication object * @returns {Object} - The claim libraries object * - claimLibraries: ClaimLibrary[] - The claim libraries * - isLoadingClaims: boolean - Whether the claims are loading + * - error: string | null - Error message if claims fetch failed * - fetchClaims: function - The function to fetch the claims */ export const useClaimActions = (auth: Auth | null) => { const [claimLibraries, setClaimLibraries] = useState([]); const [isLoadingClaims, setIsLoadingClaims] = useState(false); + const [error, setError] = useState(null); + const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false); useEffect(() => { if (auth) fetchClaims(); @@ -34,19 +38,43 @@ export const useClaimActions = (auth: Auth | null) => { const fetchClaims = useCallback(async () => { if (!auth) return; + setIsLoadingClaims(true); + setError(null); // Clear previous errors - const response = await actionWebInvoke<{ claimLibraries: ClaimLibrary[] }>( - actions[GET_CLAIMS_ACTION], - auth.imsToken, - auth.imsOrg - ); - setClaimLibraries(response as unknown as ClaimLibrary[]); - setIsLoadingClaims(false); + try { + // Fetch data from backend (could be AEM fragments or local claims) + const response = await actionWebInvoke( + actions[GET_CLAIMS_ACTION], + auth.imsToken, + auth.imsOrg + ); + + if (response && typeof response === "object") { + if (CLAIM_PROVIDER_TYPE === 'aem') { + processAEMResponse(response, setError, setClaimLibraries); + } else { + processLocalResponse(response, setError, setClaimLibraries); + } + } else { + setError("Invalid response from backend"); + setClaimLibraries([]); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to fetch claims. Please check your connection and try again."; + setError(errorMessage); + setClaimLibraries([]); + } finally { + setIsLoadingClaims(false); + setHasAttemptedFetch(true); // Mark that we've attempted at least one fetch + } }, [auth]); return { claimLibraries, isLoadingClaims, + error, + hasAttemptedFetch, + fetchClaims, }; }; diff --git a/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/utils/responseProcessors.ts b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/utils/responseProcessors.ts new file mode 100644 index 0000000..e5bbd27 --- /dev/null +++ b/genstudio-mlr-claims-app/src/genstudiopem/web-src/src/utils/responseProcessors.ts @@ -0,0 +1,63 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { ClaimLibrary } from "../types"; +import { processAEMFragments, AEMFragment } from "../components/PromptDialog/ClaimsProcessor"; + +/** + * Processes AEM provider response containing fragments + */ +export function processAEMResponse( + response: { fragments: AEMFragment[] }, + setError: (error: string | null) => void, + setClaimLibraries: (libraries: ClaimLibrary[]) => void +): void { + + const processResult = processAEMFragments(response.fragments); + + if (processResult.totalFragments === 0) { + setError("No matching fragment available"); + setClaimLibraries([]); + } else if (!processResult.hasValidFragments) { + setError("No valid claim fragments found. Please check your content fragment structure."); + setClaimLibraries([]); + } else { + setClaimLibraries(Array.isArray(processResult.claimLibraries) + ? processResult.claimLibraries + : [processResult.claimLibraries as any]); + setError(null); + } +} + +/** + * Processes Local provider response containing claims + */ +export function processLocalResponse( + response: ClaimLibrary[] | { claims: any[] } | any, + setError: (error: string | null) => void, + setClaimLibraries: (libraries: ClaimLibrary[]) => void +): void { + // Handle different local response formats + if (Array.isArray(response)) { + setClaimLibraries(response); + setError(null); + } else if (response && typeof response === "object" && response.claims) { + setClaimLibraries(Array.isArray(response.claims) ? [response as ClaimLibrary] : response.claims); + setError(null); + } else if (response && typeof response === "object" && Object.keys(response).length > 0) { + setClaimLibraries([response as ClaimLibrary]); + setError(null); + } else { + setError("No claims available from local provider"); + setClaimLibraries([]); + } +} \ No newline at end of file