{"version":3,"file":"AchievementValidator.mjs","names":["AchievementValidator","errors: string[]","warnings: string[]"],"sources":["../../src/services/AchievementValidator.ts"],"sourcesContent":["/**\n * Achievement Validator Service\n *\n * Validates Achievement structures before credential issuance according to\n * OpenBadges 3.0 specification requirements.\n *\n * @see https://www.imsglobal.org/spec/ob/v3p0/#achievement\n */\n\nimport { injectable } from '@credo-ts/core'\n\n/**\n * Result of an achievement validation\n */\nexport interface ValidationResult {\n  /** Whether the achievement is valid */\n  valid: boolean\n  /** Critical errors that must be fixed */\n  errors: string[]\n  /** Recommendations that don't prevent issuance */\n  warnings: string[]\n}\n\n/**\n * Image object structure\n */\nexport interface AchievementImage {\n  /** URI of the image */\n  id: string\n  /** Must be 'Image' */\n  type?: 'Image'\n  /** Caption for the image */\n  caption?: string\n}\n\n/**\n * Criteria for achieving the achievement\n */\nexport interface AchievementCriteria {\n  /** URI to criteria document */\n  id?: string\n  /** Human-readable narrative describing how to achieve */\n  narrative?: string\n}\n\n/**\n * Alignment to external standards or frameworks\n */\nexport interface Alignment {\n  /** Must be 'Alignment' */\n  type: 'Alignment'\n  /** Name of the target standard/framework */\n  targetName: string\n  /** URL of the target standard/framework */\n  targetUrl: string\n  /** Description of the target */\n  targetDescription?: string\n  /** Name of the framework */\n  targetFramework?: string\n  /** Code within the framework */\n  targetCode?: string\n}\n\n/**\n * Related achievement reference\n */\nexport interface Related {\n  /** URI of the related achievement */\n  id: string\n  /** Type (should include 'Related') */\n  type?: string | string[]\n  /** Optional version */\n  version?: string\n}\n\n/**\n * Result description for rubric-based achievements\n */\nexport interface ResultDescription {\n  /** URI for this result description */\n  id: string\n  /** Must be 'ResultDescription' */\n  type: 'ResultDescription'\n  /** Name of the result */\n  name: string\n  /** Rubric criteria text */\n  resultType?: string\n}\n\n/**\n * Achievement structure as per OpenBadges 3.0 spec\n */\nexport interface Achievement {\n  /** Must include 'Achievement' */\n  type: 'Achievement' | ['Achievement', ...string[]]\n  /** URI identifying this achievement */\n  id?: string\n  /** Name of the achievement (REQUIRED) */\n  name: string\n  /** Description of the achievement */\n  description?: string\n  /** Criteria for earning this achievement */\n  criteria?: AchievementCriteria\n  /** Image representing the achievement */\n  image?: AchievementImage | string\n  /** Type of achievement (e.g., 'Certificate', 'Badge') */\n  achievementType?: string\n  /** Alignment to external frameworks */\n  alignment?: Alignment[]\n  /** Creator/issuer of the achievement definition */\n  creator?: { id: string; type?: string; name?: string }\n  /** Evidence requirements */\n  creditsAvailable?: number\n  /** Human-readable code */\n  humanCode?: string\n  /** Related achievements */\n  related?: Related[]\n  /** Result descriptions for rubrics */\n  resultDescription?: ResultDescription[]\n  /** Specialization of another achievement */\n  specialization?: string\n  /** Tags for categorization */\n  tag?: string[]\n  /** Version of the achievement */\n  version?: string\n}\n\n/**\n * URL validation regex\n */\nconst URL_REGEX = /^(https?|urn):\\/?\\/?[^\\s]+$/i\n\n/**\n * Validates Achievement structures before credential issuance\n */\n@injectable()\nexport class AchievementValidator {\n  /**\n   * Validates the basic structure of an Achievement\n   * Checks for required fields and correct types\n   */\n  public validateStructure(achievement: Partial<Achievement>): ValidationResult {\n    const errors: string[] = []\n    const warnings: string[] = []\n\n    // Required field: name\n    if (!achievement.name || typeof achievement.name !== 'string') {\n      errors.push('Achievement.name is required and must be a string')\n    } else if (achievement.name.trim().length === 0) {\n      errors.push('Achievement.name must not be empty')\n    }\n\n    // Required field: type must include 'Achievement'\n    if (!achievement.type) {\n      errors.push('Achievement.type is required')\n    } else {\n      const types = Array.isArray(achievement.type) ? achievement.type : [achievement.type]\n      if (!types.includes('Achievement')) {\n        errors.push(\"Achievement.type must include 'Achievement'\")\n      }\n    }\n\n    // Recommended: id\n    if (!achievement.id) {\n      warnings.push('Achievement.id is recommended for persistent identification')\n    } else if (typeof achievement.id === 'string' && !URL_REGEX.test(achievement.id)) {\n      warnings.push('Achievement.id should be a valid URI (URL or URN)')\n    }\n\n    // Recommended: description\n    if (!achievement.description) {\n      warnings.push('Achievement.description is recommended for accessibility')\n    }\n\n    return {\n      valid: errors.length === 0,\n      errors,\n      warnings,\n    }\n  }\n\n  /**\n   * Validates achievement criteria\n   * Criteria must have either an id (URL) or a narrative, preferably both\n   */\n  public validateCriteria(achievement: Partial<Achievement>): ValidationResult {\n    const errors: string[] = []\n    const warnings: string[] = []\n\n    if (!achievement.criteria) {\n      warnings.push('Achievement.criteria is recommended')\n      return { valid: true, errors, warnings }\n    }\n\n    const criteria = achievement.criteria\n\n    // At least one of id or narrative should be present\n    if (!criteria.id && !criteria.narrative) {\n      errors.push('Achievement.criteria must have either id (URL) or narrative')\n    }\n\n    // If id is present, validate URL format\n    if (criteria.id && !URL_REGEX.test(criteria.id)) {\n      errors.push('Achievement.criteria.id must be a valid URL')\n    }\n\n    // Narrative should not be empty if present\n    if (criteria.narrative !== undefined && criteria.narrative.trim().length === 0) {\n      errors.push('Achievement.criteria.narrative must not be empty if provided')\n    }\n\n    // Recommend having both\n    if (criteria.id && !criteria.narrative) {\n      warnings.push('Consider adding criteria.narrative for human readability')\n    }\n    if (criteria.narrative && !criteria.id) {\n      warnings.push('Consider adding criteria.id for machine-readable reference')\n    }\n\n    return {\n      valid: errors.length === 0,\n      errors,\n      warnings,\n    }\n  }\n\n  /**\n   * Validates achievement image\n   */\n  public validateImage(achievement: Partial<Achievement>): ValidationResult {\n    const errors: string[] = []\n    const warnings: string[] = []\n\n    if (!achievement.image) {\n      warnings.push('Achievement.image is recommended for visual representation')\n      return { valid: true, errors, warnings }\n    }\n\n    const image = achievement.image\n\n    // Image can be a string URL or an object\n    if (typeof image === 'string') {\n      if (!URL_REGEX.test(image)) {\n        errors.push('Achievement.image must be a valid URL')\n      }\n    } else if (typeof image === 'object') {\n      if (!image.id) {\n        errors.push('Achievement.image.id is required')\n      } else if (!URL_REGEX.test(image.id)) {\n        errors.push('Achievement.image.id must be a valid URL')\n      }\n    } else {\n      errors.push('Achievement.image must be a URL string or an Image object')\n    }\n\n    return {\n      valid: errors.length === 0,\n      errors,\n      warnings,\n    }\n  }\n\n  /**\n   * Validates alignment array\n   */\n  public validateAlignment(alignments: Alignment[] | undefined): ValidationResult {\n    const errors: string[] = []\n    const warnings: string[] = []\n\n    if (!alignments || alignments.length === 0) {\n      return { valid: true, errors, warnings }\n    }\n\n    alignments.forEach((alignment, index) => {\n      const prefix = `Alignment[${index}]`\n\n      // Required: type\n      if (alignment.type !== 'Alignment') {\n        errors.push(`${prefix}.type must be 'Alignment'`)\n      }\n\n      // Required: targetName\n      if (!alignment.targetName || typeof alignment.targetName !== 'string') {\n        errors.push(`${prefix}.targetName is required`)\n      }\n\n      // Required: targetUrl\n      if (!alignment.targetUrl) {\n        errors.push(`${prefix}.targetUrl is required`)\n      } else if (!URL_REGEX.test(alignment.targetUrl)) {\n        errors.push(`${prefix}.targetUrl must be a valid URL`)\n      }\n\n      // Recommended fields\n      if (!alignment.targetDescription) {\n        warnings.push(`${prefix}.targetDescription is recommended`)\n      }\n    })\n\n    return {\n      valid: errors.length === 0,\n      errors,\n      warnings,\n    }\n  }\n\n  /**\n   * Validates result descriptions (for rubric-based achievements)\n   */\n  public validateResultDescriptions(resultDescriptions: ResultDescription[] | undefined): ValidationResult {\n    const errors: string[] = []\n    const warnings: string[] = []\n\n    if (!resultDescriptions || resultDescriptions.length === 0) {\n      return { valid: true, errors, warnings }\n    }\n\n    resultDescriptions.forEach((rd, index) => {\n      const prefix = `ResultDescription[${index}]`\n\n      // Required: id\n      if (!rd.id) {\n        errors.push(`${prefix}.id is required`)\n      }\n\n      // Required: type\n      if (rd.type !== 'ResultDescription') {\n        errors.push(`${prefix}.type must be 'ResultDescription'`)\n      }\n\n      // Required: name\n      if (!rd.name || typeof rd.name !== 'string') {\n        errors.push(`${prefix}.name is required`)\n      }\n    })\n\n    return {\n      valid: errors.length === 0,\n      errors,\n      warnings,\n    }\n  }\n\n  /**\n   * Validates all aspects of an Achievement\n   * Combines structure, criteria, image, and alignment validation\n   */\n  public validateAll(achievement: Partial<Achievement>): ValidationResult {\n    const results = [\n      this.validateStructure(achievement),\n      this.validateCriteria(achievement),\n      this.validateImage(achievement),\n      this.validateAlignment(achievement.alignment),\n      this.validateResultDescriptions(achievement.resultDescription),\n    ]\n\n    const errors = results.flatMap((r) => r.errors)\n    const warnings = results.flatMap((r) => r.warnings)\n\n    return {\n      valid: errors.length === 0,\n      errors,\n      warnings,\n    }\n  }\n\n  /**\n   * Quick check if an achievement is minimally valid\n   * Only checks required fields, not recommendations\n   */\n  public isValid(achievement: Partial<Achievement>): boolean {\n    return this.validateAll(achievement).valid\n  }\n}\n"],"mappings":"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"}