diff --git a/.env.example b/.env.example index aefa4dc..13efcd5 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ PUBLIC_PUBLISHER_API_ENV_LABEL="(Sandbox)" PUBLIC_UI_API_BASEURL="/publisher" ADAPTER_STATIC="false" +# OPTIONAL, DEV-ONLY: bypass Publisher login for local testing. +# When "true", the login screen shows a "Skip login (dev)" button that injects a +# fake publisher session so you can exercise badge-source workflows (Accredible, +# Canvas, etc.) without a working Publisher login. Publisher environments on +# Keycloak/OIDC (e.g. Sandbox) no longer support the email/password login this +# app uses, so this is the way to test locally against them. +# Leave "false"/unset in any deployed build. Saving to the Publisher still +# requires a real, environment-issued token, so the final save step will fail. +PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN="false" +PUBLIC_DEV_PUBLISHER_ORG_CTID="" # optional: org CTID to attach drafts to; defaults to a placeholder +PUBLIC_DEV_PUBLISHER_ORG_NAME="" # optional: display name for the fake org + # OPTIONAL Canvas Login Settings per environment: only for use on production. # It is OK that client secret is readable by the user, because the exchange is # protected with PKCE and exact-match redirect URIs set by Canvas admins. @@ -48,4 +60,8 @@ PUBLIC_PARCHMENT_EU_LOGIN_CLIENT_SECRET="" PUBLIC_PARCHMENT_US_ENABLED="true" PUBLIC_PARCHMENT_US_LOGIN_CLIENT_ID="" -PUBLIC_PARCHMENT_US_LOGIN_CLIENT_SECRET="" \ No newline at end of file +PUBLIC_PARCHMENT_US_LOGIN_CLIENT_SECRET="" + +PUBLIC_PARCHMENT_SG_ENABLED="true" +PUBLIC_PARCHMENT_SG_LOGIN_CLIENT_ID="" +PUBLIC_PARCHMENT_SG_LOGIN_CLIENT_SECRET="" \ No newline at end of file diff --git a/local.env b/local.env index be7bb71..818a9ed 100644 --- a/local.env +++ b/local.env @@ -3,5 +3,10 @@ PUBLIC_PUBLISHER_API_BASEURL="https://localhost:44330/" PUBLIC_PUBLISHER_API_ENV_LABEL="(local)" PUBLIC_UI_API_BASEURL="/" ADAPTER_STATIC="false" + +# DEV-ONLY: bypass Publisher login for local testing (see .env.example for details). +PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN="false" +PUBLIC_DEV_PUBLISHER_ORG_CTID="" # optional: org CTID to attach drafts to; defaults to a placeholder +PUBLIC_DEV_PUBLISHER_ORG_NAME="" # optional: display name for the fake org PUBLIC_CANVAS_TEST_LOGIN_CLIENT_ID="" PUBLIC_CANVAS_TEST_LOGIN_CLIENT_SECRET="" diff --git a/src/lib/partials/AccredibleConfig.svelte b/src/lib/partials/AccredibleConfig.svelte new file mode 100644 index 0000000..785c282 --- /dev/null +++ b/src/lib/partials/AccredibleConfig.svelte @@ -0,0 +1,174 @@ + + +

Configure Accredible connection

+ + + You'll need an Accredible API key with issuer access, found in Accredible under Settings > + API & Integrations. Use a Sandbox key when testing against a Sandbox account. + + +
+ +
+ + +{#if $accredibleSelectedRegion} +
+ +
+
+
+ + {#if accredibleApiKeyHidden} + + {:else} + + {/if} +
+
+{/if} + +{#if $accredibleSelectedRegion && $accredibleApiKey} +
+ +
+
+
+ + +
+
+{/if} + +{#if $accredibleSelectedRegion && $accredibleApiKey && $accredibleAgreeTerms} +
+ +
+ {#await loadGroupsPromise} +
+
+
+ Loading... +
+ {:then} + {#if $accredibleGroups.length} +
+ + Found {$accredibleGroups.length} + {$accredibleGroups.length == 1 ? 'badge' : 'badges'} on Accredible. + + + Skill/outcome alignments that don't resolve to a URL (framework code) will be omitted + from the imported data, since Badge Publisher requires a URL on every alignment entry. + +
+ {:else} +
+ +
+ {/if} + {:catch} + + {/await} +{/if} diff --git a/src/lib/partials/BadgeSourceConfig.svelte b/src/lib/partials/BadgeSourceConfig.svelte index fdda1dd..d153eed 100644 --- a/src/lib/partials/BadgeSourceConfig.svelte +++ b/src/lib/partials/BadgeSourceConfig.svelte @@ -9,6 +9,7 @@ import NextPrevButton from '$lib/components/NextPrevButton.svelte'; import CanvasConfig from '$lib/partials/CanvasConfig.svelte'; import CredlyConfig from '$lib/partials/CredlyConfig.svelte'; + import AccredibleConfig from '$lib/partials/AccredibleConfig.svelte'; import AdvancedBadgeInput from './AdvancedBadgeInput.svelte'; import BadgeSelection from '$lib/partials/BadgeSelection.svelte'; import { @@ -110,6 +111,14 @@ on:select={(e) => ($badgeSourceType = e.detail.value)} description="A leading badge platform focused on resume-ready achievements in education, workforce, and professional development." /> + ($badgeSourceType = e.detail.value)} + description="A digital credentialing platform for badges, certificates, and diplomas." + /> {:else if $badgeSourceType == 'credly'} + {:else if $badgeSourceType == 'accredible'} + {:else if $badgeSourceType == 'json'} {/if} diff --git a/src/lib/partials/PublisherConfig.svelte b/src/lib/partials/PublisherConfig.svelte index 2b75bb4..96ce627 100644 --- a/src/lib/partials/PublisherConfig.svelte +++ b/src/lib/partials/PublisherConfig.svelte @@ -16,6 +16,8 @@ PUBLIC_PUBLISHER_API_BASEURL, PUBLIC_PUBLISHER_API_ENV_LABEL } from '$env/static/public'; + // Optional, dev-only settings. Read via dynamic env so builds don't break when unset. + import { env as publicDynamicEnv } from '$env/dynamic/public'; import { getUser, publisherUser, @@ -68,40 +70,122 @@ return; }) .then(async (valid) => { + // If validation failed, the .catch above resolves to undefined; abort here + // so we don't fire a login request (or spin) with invalid input. + if (!valid) return; + userIsLoading = true; const url = `${PUBLIC_UI_API_BASEURL}/StagingApi/Login`; - const response = await fetch(url, { - method: 'POST', - body: JSON.stringify(formData), - headers: { - 'Content-Type': 'application/json' - } - }); - const responseData = await response.json(); - if (!responseData['Valid']) { - let errorMessage: string; + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(formData), + headers: { + 'Content-Type': 'application/json' + } + }); + + // The Publisher may respond with a non-JSON body (e.g. an HTML Keycloak + // sign-in page) once an environment migrates to interactive OIDC login. + // Parsing that as JSON used to throw silently and leave the spinner running + // forever, so handle it explicitly with a helpful message. + let responseData: any; try { - errorMessage = responseData.Messages[0] || responseData.message; + responseData = await response.json(); } catch { - errorMessage = 'Unexpected server error!'; + setAlert( + 'error', + `The Publisher did not return a valid login response (HTTP ${response.status}). ` + + `This environment likely requires interactive sign-in (Keycloak) and no longer ` + + `supports email/password login from this app.`, + 'Authentication error:' + ); + userIsLoading = false; + return; + } + + if (!responseData['Valid']) { + let errorMessage: string; + try { + errorMessage = responseData.Messages[0] || responseData.message; + } catch { + errorMessage = 'Unexpected server error!'; + } + + setAlert('error', errorMessage, 'Authentication error:'); + userIsLoading = false; + return; } - setAlert('error', errorMessage, 'Authentication error:'); + // reset form and save user + registryEmailAddress = ''; + registryPassword = ''; + registryAgreeTerms = false; + publisherUser.set({ user: responseData['Data'] }); + userIsLoading = false; + $publisherSetupStep = 2; + refreshCredentialTypes(); + } catch (e) { + setAlert( + 'error', + `Could not reach the Publisher login service: ${String(e)}`, + 'Authentication error:' + ); userIsLoading = false; - return; } - - // reset form and save user - registryEmailAddress = ''; - registryPassword = ''; - registryAgreeTerms = false; - publisherUser.set({ user: responseData['Data'] }); - userIsLoading = false; - $publisherSetupStep = 2; - refreshCredentialTypes(); }); }; + // --------------------------------------------------------------------------- + // DEV-ONLY: skip Publisher login for local testing. + // + // The email/password login above only works against Publisher environments + // that still support it. Environments on Keycloak/OIDC (e.g. Sandbox) return + // an HTML sign-in page instead of a token, which blocks local testing of the + // badge-source workflows (Accredible, Canvas, etc.). + // + // When PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN is "true", we inject a fake publisher + // session (user + selected org + placeholder verification service) directly + // into the stores and jump straight to the completed state. This lets you + // exercise everything up to (but not including) the real "Save to Publisher" + // step, whose API calls still require a valid, environment-issued token. + // + // This is gated on an env flag that ships disabled; it is a no-op in prod. + // --------------------------------------------------------------------------- + const devBypassEnabled = publicDynamicEnv.PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN === 'true'; + const devOrgCtid = + publicDynamicEnv.PUBLIC_DEV_PUBLISHER_ORG_CTID || + 'ce-00000000-0000-0000-0000-000000000000'; + const devOrgName = publicDynamicEnv.PUBLIC_DEV_PUBLISHER_ORG_NAME || 'Dev Test Organization'; + + const devBypassLogin = () => { + resetAlert(); + const fakeOrg = { + Id: '0', + RowId: '00000000-0000-0000-0000-000000000000', + Name: devOrgName, + CTID: devOrgCtid, + Type: 'Organization' + }; + publisherUser.set({ + user: { + Id: 0, + Name: 'Dev Tester', + Email: 'dev@example.com', + IsSiteStaff: false, + Token: 'DEV_FAKE_TOKEN', + Organizations: [fakeOrg] + } + }); + publisherOrganization.set({ org: fakeOrg }); + publisherVerificationService.set(devOrgCtid); + // Credentials list stays empty; we skip the API-backed preview entirely. + panelIsHidden = true; + $publisherSetupStep = 4; + if ($badgeSetupStep == 0) $badgeSetupStep = 1; + refreshCredentialTypes(); + }; + const publisherUrl = new URL(PUBLIC_PUBLISHER_API_BASEURL); const accountSettingsUrl = new URL('/accounts/Dashboard', publisherUrl.origin).href; let userPromise = new Promise((resolve, reject) => {}); // use await block to show loading spinner to start @@ -270,6 +354,22 @@
+ + {#if devBypassEnabled} +
+ + Developer mode: skip Publisher login + and inject a fake session (org + {devOrgCtid}) so you can test badge-source + workflows locally. Saving to the Publisher will still require a real account. + +
+ +
+
+ {/if} {:else if userIsLoading}
diff --git a/src/lib/stores/badgeSourceStore.ts b/src/lib/stores/badgeSourceStore.ts index 1fdd16a..f40ac27 100644 --- a/src/lib/stores/badgeSourceStore.ts +++ b/src/lib/stores/badgeSourceStore.ts @@ -11,9 +11,21 @@ import { writable, derived, get, type Readable } from 'svelte/store'; import { PUBLIC_UI_API_BASEURL } from '$env/static/public'; import { publisherUser } from '$lib/stores/publisherStore.js'; import { badgeclassFromParchmentApiBadge, type ParchmentBadge, type ParchmentEnvKey, type ParchmentIssuer, parchmentRegions } from '$lib/utils/parchment.js'; +import { + accredibleAuthHeader, + accredibleDesignEndpoint, + accredibleDesignPreviewEndpoint, + accredibleRegions, + badgeclassFromAccredibleGroup, + badgeDesignIdForGroup, + imageUrlFromDesign, + type AccredibleEnvKey, + type AccredibleGroup +} from '$lib/utils/accredible.js'; export enum BadgeSourceTypeOptions { None = '', + Accredible = 'accredible', Canvas = 'canvas', Credly = 'credly', JSON = 'json', @@ -175,6 +187,144 @@ export const fetchParchmentIssuerBadges = async (): Promise => { return true; }; +// Accredible configuration +export const accredibleApiKey = writable(''); +export const accredibleAgreeTerms = writable(false); +export const accredibleSelectedRegion = writable(''); +export const accredibleGroups = writable([]); + +export const fetchAccredibleGroups = async (): Promise => { + const region = get(accredibleSelectedRegion); + const apiKey = get(accredibleApiKey); + if (!region || !get(accredibleAgreeTerms) || !apiKey) return false; + + const env = accredibleRegions.get(region); + if (!env) return false; + + const proxyRequestHeaders = new Headers(); + proxyRequestHeaders.append('Content-Type', 'application/json'); + if (get(publisherUser).user?.Token) + proxyRequestHeaders.append('Authorization', `Bearer ${get(publisherUser).user?.Token}`); + + // Accredible's `/v1/issuer/all_groups` endpoint is paginated (page/page_size, + // matching Accredible's own export script). We page through until a page + // comes back with fewer than page_size results. + const pageSize = 50; + let page = 1; + let allGroups: AccredibleGroup[] = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const requestData = { + URL: `${env.apiDomain}/v1/issuer/all_groups?page=${page}&page_size=${pageSize}`, + Method: 'GET', + Body: null, + Headers: [accredibleAuthHeader(apiKey), { Name: 'Accept', Value: 'application/json' }] + }; + + const proxyResponse = await fetch(`${PUBLIC_UI_API_BASEURL}/StagingApi/Proxy`, { + method: 'POST', + body: JSON.stringify(requestData), + headers: proxyRequestHeaders + }); + const proxyResponseData = await proxyResponse.json(); + + if (!proxyResponseData.Valid || proxyResponseData.Data?.StatusCode != '200') { + const status = proxyResponseData.Data?.StatusCode ?? proxyResponseData.StatusCode; + const detail = + proxyResponseData.Data?.Body || proxyResponseData.StatusMessage || 'no response body'; + const hint = + status == 401 || status == 403 + ? ' Check that the API key is correct and matches the selected region.' + : ''; + throw new Error( + `Error fetching group data from Accredible (status ${status ?? 'unknown'}).${hint} ` + + `Details: ${String(detail).slice(0, 300)}` + ); + } + + const body = JSON.parse(proxyResponseData.Data?.Body); + // NOTE: the exact envelope key returned by this endpoint was not confirmed + // against Accredible's API reference (unreachable while building this). + // Handle a few plausible shapes defensively; adjust once confirmed. + const pageGroups: AccredibleGroup[] = body.groups || body.all_groups || (Array.isArray(body) ? body : []); + + allGroups = [...allGroups, ...pageGroups]; + + if (pageGroups.length < pageSize) break; + page += 1; + if (page > 200) break; // safety valve against an unexpected infinite loop + } + + // Best-effort: resolve a badge image for each group from its Design. + // The group payload carries no image, only design ids; for a badge we use + // `badge_design_id` (falling back to the primary/default design). An image + // lookup failure is logged and never breaks the overall fetch. + const proxyRequest = async ( + method: string, + url: string, + body: string | null = null + ): Promise => { + const response = await fetch(`${PUBLIC_UI_API_BASEURL}/StagingApi/Proxy`, { + method: 'POST', + body: JSON.stringify({ + URL: url, + Method: method, + Body: body, + Headers: [ + accredibleAuthHeader(apiKey), + { Name: 'Accept', Value: 'application/json' }, + { Name: 'Content-Type', Value: 'application/json' } + ] + }), + headers: proxyRequestHeaders + }); + const data = await response.json(); + if (data.Valid && data.Data?.StatusCode == '200') return JSON.parse(data.Data.Body); + return null; + }; + + // The design's rasterized image is a BLANK template (no data merged), so to + // match how Accredible renders the badge we POST to the design /preview + // endpoint with the group's display name merged in (`group.course_name` / + // `group.name`), which returns a rendered `{ link }`. We fall back to the + // blank rasterized image only if the merged render fails. Because the merged + // image depends on the name, it's per-group; we cache by design+name since + // groups can share a design and name. + const imageCache = new Map(); + for (const g of allGroups) { + if (g.image_url) continue; + const designId = badgeDesignIdForGroup(g); + if (designId === undefined) continue; + const name = g.course_name || g.name || ''; + const cacheKey = `${designId}|${name}`; + if (imageCache.has(cacheKey)) { + g.image_url = imageCache.get(cacheKey); + continue; + } + try { + // Render the design with the group's name merged in. + const previewBody = JSON.stringify({ 'group.course_name': name, 'group.name': name }); + let img = imageUrlFromDesign( + await proxyRequest('POST', accredibleDesignPreviewEndpoint(env, designId), previewBody) + ); + if (!img) { + // Fallback: the design's blank rasterized image (no name merged). + img = imageUrlFromDesign(await proxyRequest('GET', accredibleDesignEndpoint(env, designId))); + } + if (img) { + imageCache.set(cacheKey, img); + g.image_url = img; + } + } catch (e) { + console.warn(`Could not load Accredible design ${designId} for a badge image:`, e); + } + } + + accredibleGroups.set(allGroups); + return true; +}; + // Advanced JSON setup export const advancedBadges = writable>([]); export const advancedBadgesFound = derived( @@ -196,7 +346,11 @@ export const badgeSetupComplete = derived( parchmentAgreeTerms, parchmentSelectedRegion, parchmentSelectedIssuer, - parchmentOrganization + parchmentOrganization, + accredibleApiKey, + accredibleAgreeTerms, + accredibleSelectedRegion, + accredibleGroups ], ([ $advancedBadgesFound, @@ -209,7 +363,11 @@ export const badgeSetupComplete = derived( $parchmentAgreeTerms, $parchmentSelectedRegion, $parchmentSelectedIssuer, - $parchmentOrganization + $parchmentOrganization, + $accredibleApiKey, + $accredibleAgreeTerms, + $accredibleSelectedRegion, + $accredibleGroups ]) => { if ($badgeSourceType == BadgeSourceTypeOptions['Credly']) { return ( @@ -226,6 +384,13 @@ export const badgeSetupComplete = derived( !!$parchmentSelectedIssuer && !!$parchmentOrganization ); + } else if ($badgeSourceType == BadgeSourceTypeOptions['Accredible']) { + return ( + !!$accredibleApiKey && + !!$accredibleAgreeTerms && + !!$accredibleSelectedRegion && + !!$accredibleGroups.length + ); } else { return !!$advancedBadgesFound.length; } @@ -242,6 +407,8 @@ export const normalizedBadges: Readable = derived( canvasSelectedIssuerBadges, credlyIssuerBadges, parchmentSelectedIssuerBadges, + accredibleGroups, + accredibleSelectedRegion, advancedBadgesFound ], ([ @@ -250,6 +417,8 @@ export const normalizedBadges: Readable = derived( $canvasSelectedIssuerBadges, $credlyIssuerBadges, $parchmentSelectedIssuerBadges, + $accredibleGroups, + $accredibleSelectedRegion, $advancedBadgesFound ]) => { if (!$badgeSetupComplete) { @@ -263,6 +432,10 @@ export const normalizedBadges: Readable = derived( return $credlyIssuerBadges.map(badgeclassFromCredlyApiBadge); } else if (get(badgeSourceType) == BadgeSourceTypeOptions['Parchment']) { return $parchmentSelectedIssuerBadges.map(badgeclassFromParchmentApiBadge); + } else if (get(badgeSourceType) == BadgeSourceTypeOptions['Accredible']) { + const env = $accredibleSelectedRegion ? accredibleRegions.get($accredibleSelectedRegion) : undefined; + if (!env) return []; + return $accredibleGroups.map((g) => badgeclassFromAccredibleGroup(g, env)); } else { return $advancedBadgesFound; } @@ -287,4 +460,7 @@ export const resetBadgeData = () => { parchmentIssuers.set([]); parchmentSelectedIssuer.set(undefined); parchmentSelectedIssuerBadges.set([]); + + // Does not invalidate accredibleApiKey + accredibleGroups.set([]); }; diff --git a/src/lib/stores/publisherStore.ts b/src/lib/stores/publisherStore.ts index afd0fcd..4a9e6ff 100644 --- a/src/lib/stores/publisherStore.ts +++ b/src/lib/stores/publisherStore.ts @@ -1330,12 +1330,19 @@ const createCredentialDraftStore = () => { subscribe, importCheckedSourceBadges: () => { const checkedBadgeKeys = get(checkedBadges); - set( - get(normalizedBadges) - .filter((b) => checkedBadgeKeys[b.id] === true) - .map((bc) => badgeClassToCtdlApiCredential(bc)) - .sort((a, b) => a.Credential.Name.localeCompare(b.Credential.Name)) - ); + const drafts: CtdlCredentialDraft[] = []; + get(normalizedBadges) + .filter((b) => checkedBadgeKeys[b.id] === true) + .forEach((bc) => { + // Convert per-badge so one malformed badge can't abort the whole + // import (which would silently leave the drafts list empty). + try { + drafts.push(badgeClassToCtdlApiCredential(bc)); + } catch (e) { + console.error(`Failed to import badge "${bc.name}" (${bc.id}):`, e); + } + }); + set(drafts.sort((a, b) => a.Credential.Name.localeCompare(b.Credential.Name))); }, updateCredential: (b: CtdlCredentialDraft) => { update((credentialList) => { diff --git a/src/lib/utils/accredible.ts b/src/lib/utils/accredible.ts new file mode 100644 index 0000000..1bed18d --- /dev/null +++ b/src/lib/utils/accredible.ts @@ -0,0 +1,257 @@ +import type { Alignment, BadgeClassCTDLExtended } from '$lib/utils/badges.js'; + +// Accredible Options +// +// Auth header format and the Groups endpoint path/pagination params below are +// taken directly from Accredible's own export script +// (github.com/accredible/accredible-achievement-ob3-export, accredible_ob3_export.py): +// request.add_header("Authorization", f"Token token={self.api_key}") +// self._get("/v1/issuer/all_groups", { page, page_size }) +// +// The exact JSON envelope key for the groups list, and any endpoint for +// fetching a badge design/image, were NOT confirmed against Accredible's API +// reference (docs.accredible.com was not reachable while building this) -- +// see the TODOs below. Confirm both with Accredible before relying on this +// in production. + +export type AccredibleEnvKey = 'us' | 'eu' | 'sandbox'; + +export interface AccredibleEnv { + id: AccredibleEnvKey; + apiDomain: string; + credentialDomain: string; + name: string; +} + +export const accredibleRegions: Map = new Map([ + [ + 'us', + { + id: 'us', + apiDomain: 'https://api.accredible.com', + credentialDomain: 'https://www.credential.net', + name: 'United States (production)' + } + ], + [ + 'eu', + { + id: 'eu', + apiDomain: 'https://eu.api.accredible.com', + credentialDomain: 'https://eu.credential.net', + name: 'Europe (production)' + } + ], + [ + 'sandbox', + { + id: 'sandbox', + apiDomain: 'https://sandbox.api.accredible.com', + credentialDomain: 'https://sandbox.credential.net', + name: 'Sandbox' + } + ] +]); + +// A single "learning outcome" / skill entry as returned by Accredible. Accredible's +// export script emits these as bare names unless run with --resolve-skills, in +// which case framework-matched skills also carry targetUrl/targetFramework/targetCode. +export interface AccredibleLearningOutcome { + name?: string; + targetUrl?: string; + targetFramework?: string; + targetCode?: string; +} + +// A single earning-criterion entry as returned by Accredible. Sandbox groups +// return `earning_criteria` as an array of these objects (each `text` is an +// HTML fragment), NOT as a plain string. Confirmed against a live sandbox +// probe (see accredible_probe.py output). +export interface AccredibleCriterion { + id?: string; + kind?: string; // e.g. "degree", "skill", "completion" + text?: string; // HTML fragment describing the criterion + required?: boolean; + position?: number; +} + +// Raw shape of one entry from Accredible's `/v1/issuer/all_groups` endpoint, +// limited to the fields Accredible's own export script reads. +export interface AccredibleGroup { + id: number | string; + name?: string; + course_name?: string; + course_description?: string; + description?: string; + // May be a plain string OR a structured array of criterion objects, + // depending on how the group's criteria were configured in Accredible. + earning_criteria?: string | AccredibleCriterion[]; + achievement_type?: string; + // Groups render credentials using reusable Designs, referenced by id. The + // group payload has no image itself; the badge image comes from a Design + // (see fetchAccredibleGroups() / GET /v1/designs/{design_id}). A group can + // carry several design ids -- for a badge we want `badge_design_id`. + design_id?: number | string; + badge_design_id?: number | string; + certificate_design_id?: number | string; + primary_design_id?: number | string; + design_name?: string; + // Populated by fetchAccredibleGroups() after resolving the Design, so + // extractImageFromGroup() below can pick it up. Not returned by Accredible. + image_url?: string; + learning_outcomes?: Array; + // TODO(confirm with Accredible): if a badge design/image URL is available + // directly on the group payload under some other field name (e.g. + // `design`, `badge_design`, `image_url`), add it here and in + // extractImageFromGroup() below so we can avoid a second API call per badge. +} + +const stripHtml = (html: string): string => { + if (!html) return ''; + return html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +}; + +// Normalize Accredible's `earning_criteria` (which may be a plain string or a +// structured array of criterion objects) into a single narrative string. +// Downstream code (badgeClassToCtdlApiCredential) runs this through +// markdownToTxt(), which requires a string -- passing the raw array throws and +// silently drops the whole credential from the import. +const narrativeFromEarningCriteria = ( + earning: string | AccredibleCriterion[] | undefined, + fallback: string +): string => { + if (!earning) return fallback; + if (typeof earning === 'string') return earning; + if (Array.isArray(earning)) { + const parts = [...earning] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .map((c) => { + const text = stripHtml(c.text || ''); + if (!text) return ''; + return c.required === false ? `${text} (optional)` : text; + }) + .filter((t) => t.length > 0); + return parts.length ? parts.join('\n\n') : fallback; + } + return fallback; +}; + +// Best-effort attempt to find an already-present image URL on the group +// payload before resorting to a second lookup call. Field names are guesses +// pending confirmation from Accredible -- extend this list once known. +const extractImageFromGroup = (g: AccredibleGroup): string => { + const candidate = + (g as any).image_url || (g as any).badge_design?.image_url || (g as any).design?.image_url; + return typeof candidate === 'string' ? candidate : ''; +}; + +/** + * Converts one Accredible group ("badge template") into the shape Badge + * Publisher's `badgeClassBasicSchema` (src/lib/utils/badges.ts) validates. + * + * IMPORTANT: alignment entries without a resolvable `targetUrl` are dropped + * here rather than passed through with only `targetName`. Badge Publisher's + * importer requires `targetUrl` to be a valid URL on every alignment entry + * and rejects the *entire* achievement if even one entry is missing it, with + * no special case for free-text skills. This was verified against a 316-item + * sample produced by Accredible's export script: 255/316 (81%) failed import + * for exactly this reason, across 799 total alignment entries, all missing + * targetUrl. Dropping unresolvable entries lets the rest of the achievement + * (name, description, criteria) still publish, at the cost of losing the + * unresolved skill names -- which is a reasonable tradeoff since Badge + * Publisher can't accept them as-is anyway. + */ +export const badgeclassFromAccredibleGroup = ( + g: AccredibleGroup, + env: AccredibleEnv, + imageUrl?: string +): BadgeClassCTDLExtended => { + const name = g.course_name || g.name || ''; + const description = stripHtml(g.course_description || g.description || ''); + + const alignment: Alignment[] = (g.learning_outcomes || []) + .map((o): Partial => { + if (typeof o === 'string') return { targetName: o }; + return { + targetName: o.name || '', + targetUrl: o.targetUrl, + targetFramework: o.targetFramework, + targetCode: o.targetCode + }; + }) + .filter((a): a is Alignment => !!a.targetUrl && !!a.targetName); + + return { + id: `${env.credentialDomain}/group/${g.id}`, + name, + description, + image: imageUrl || extractImageFromGroup(g), + issuer: '', + achievementType: g.achievement_type || 'Achievement', + tags: [], + criteria: { + narrative: narrativeFromEarningCriteria( + g.earning_criteria, + `See ${env.credentialDomain}/group/${g.id} for details.` + ) + }, + alignment + }; +}; + +export const accredibleAuthHeader = (apiKey: string) => ({ + Name: 'Authorization', + Value: `Token token=${apiKey}` +}); + +// A Design object as returned by GET /v1/designs/{design_id}. Only the +// image-bearing fields are modeled here. `rasterized_content_url` is +// Accredible's documented "link to generate an image of the design"; the other +// keys are tolerated as fallbacks in case the account returns a different shape. +export interface AccredibleDesign { + id?: number | string; + kind?: string; // 'badge' | 'certificate' + rasterized_content_url?: string; + image_url?: string; + preview_url?: string; +} + +// The design a group uses for its BADGE image. A group carries several design +// ids; prefer the badge-specific one, then the group's primary/default design. +// (certificate_design_id is intentionally last -- it renders a certificate, not +// a badge.) Returns undefined when the group references no usable design. +export const badgeDesignIdForGroup = (g: AccredibleGroup): number | string | undefined => { + for (const candidate of [ + g.badge_design_id, + g.primary_design_id, + g.design_id, + g.certificate_design_id + ]) { + if (candidate !== undefined && candidate !== null && candidate !== '') return candidate; + } + return undefined; +}; + +// Endpoint for a single Design. A group's badge design id points here; the +// Design's rasterized image is used as the badge image. +export const accredibleDesignEndpoint = (env: AccredibleEnv, designId: string | number) => + `${env.apiDomain}/v1/designs/${designId}`; + +// Endpoint that renders a preview image of a Design and returns `{ link }`. +// Used as a fallback when the Design object itself has no rasterized image URL. +export const accredibleDesignPreviewEndpoint = (env: AccredibleEnv, designId: string | number) => + `${env.apiDomain}/v1/designs/${designId}/preview`; + +// Extract a usable image URL from a design or design-preview payload, tolerating +// either a bare object or one wrapped under a `design` key, and a few field-name +// variants (`rasterized_content_url` from GET design, `link` from the preview +// endpoint). Returns '' when nothing usable is present. +export const imageUrlFromDesign = (payload: unknown): string => { + const root = (payload ?? {}) as Record; + const d: Record = root.design ?? root; + const candidate = d.rasterized_content_url || d.image_url || d.preview_url || d.link; + return typeof candidate === 'string' ? candidate : ''; +}; diff --git a/src/routes/publisher/StagingApi/Proxy/+server.ts b/src/routes/publisher/StagingApi/Proxy/+server.ts index 10267c0..af12615 100644 --- a/src/routes/publisher/StagingApi/Proxy/+server.ts +++ b/src/routes/publisher/StagingApi/Proxy/+server.ts @@ -7,7 +7,11 @@ const ORIGIN_WHITELIST = [ 'https://api.eu.badgr.io', 'https://api.ca.badgr.io', 'https://api.test.badgr.com', - 'https://www.credly.com' + 'https://www.credly.com', + // Accredible API regions (must match apiDomain values in src/lib/utils/accredible.ts) + 'https://api.accredible.com', + 'https://eu.api.accredible.com', + 'https://sandbox.api.accredible.com' ]; export const POST: RequestHandler = async ({ request }) => {