import type { CheckInBadge, CheckInBadgeCode, CheckInProfile, CheckInRecord } from './types' import { CHECK_IN_BADGES, CHECK_IN_POINTS, cloneCheckInProfile, createCheckInRecordId, } from './check-in' const badgeCodes = new Set(CHECK_IN_BADGES.map(badge => badge.code)) const profileKeys = new Set(['points', 'badges', 'checkIns']) const recordKeys = new Set(['id', 'poiId', 'poiName', 'checkedInAt', 'pointsAwarded']) const badgeKeys = new Set(['code', 'obtainedAt']) function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function hasOnlyKeys(value: Record, allowedKeys: ReadonlySet): boolean { return Object.keys(value).every(key => allowedKeys.has(key)) } function readTimestamp(value: unknown): string | null { if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return null return new Date(value).toISOString() } function isCheckInRecordId(id: string, poiId: string, checkedInAt: string): boolean { return id === createCheckInRecordId(poiId, checkedInAt) } function normalizeCheckInRecord(value: unknown): CheckInRecord | null { if (!isRecord(value) || !hasOnlyKeys(value, recordKeys)) return null const id = typeof value.id === 'string' ? value.id.trim() : '' const poiId = typeof value.poiId === 'string' ? value.poiId.trim() : '' const poiName = typeof value.poiName === 'string' ? value.poiName.trim() : '' const checkedInAt = readTimestamp(value.checkedInAt) if (!id || !poiId || !poiName || !checkedInAt || !isCheckInRecordId(id, poiId, checkedInAt) || value.pointsAwarded !== CHECK_IN_POINTS) { return null } return { id, poiId, poiName, checkedInAt, pointsAwarded: CHECK_IN_POINTS } } function normalizeBadge(value: unknown): CheckInBadge | null { if (!isRecord(value) || !hasOnlyKeys(value, badgeKeys) || typeof value.code !== 'string' || !badgeCodes.has(value.code as CheckInBadgeCode)) { return null } const obtainedAt = readTimestamp(value.obtainedAt) return obtainedAt ? { code: value.code as CheckInBadgeCode, obtainedAt } : null } export function normalizeCheckInProfile(value: unknown): CheckInProfile | null { if (!isRecord(value) || !hasOnlyKeys(value, profileKeys) || !Array.isArray(value.checkIns) || !Array.isArray(value.badges)) { return null } const checkIns = value.checkIns.map(normalizeCheckInRecord) const badges = value.badges.map(normalizeBadge) if (checkIns.some(record => !record) || badges.some(badge => !badge)) return null const normalizedCheckIns = checkIns as CheckInRecord[] const normalizedBadges = badges as CheckInBadge[] if (new Set(normalizedCheckIns.map(record => record.poiId)).size !== normalizedCheckIns.length || new Set(normalizedCheckIns.map(record => record.id)).size !== normalizedCheckIns.length || new Set(normalizedBadges.map(badge => badge.code)).size !== normalizedBadges.length) { return null } const expectedBadgeCodes = CHECK_IN_BADGES .filter(badge => normalizedCheckIns.length >= badge.requiredCheckIns) .map(badge => badge.code) const actualBadgeCodes = normalizedBadges.map(badge => badge.code) if (expectedBadgeCodes.length !== actualBadgeCodes.length || expectedBadgeCodes.some(code => !actualBadgeCodes.includes(code))) { return null } const checkInTimes = new Map(normalizedCheckIns.map(record => [record.poiId, record.checkedInAt])) if (CHECK_IN_BADGES.some((definition) => { const badge = normalizedBadges.find(item => item.code === definition.code) const thresholdRecord = normalizedCheckIns[normalizedCheckIns.length - definition.requiredCheckIns] return badge && (!thresholdRecord || badge.obtainedAt !== checkInTimes.get(thresholdRecord.poiId)) })) { return null } const expectedPoints = normalizedCheckIns.reduce((total, record) => total + record.pointsAwarded, 0) if (value.points !== expectedPoints) return null return cloneCheckInProfile({ points: expectedPoints, badges: normalizedBadges, checkIns: normalizedCheckIns }) }