forked from zhouruizhe/gmTouringMiniApp
106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
import type { CheckInBadge, CheckInBadgeCode, CheckInProfile, CheckInRecord } from './types'
|
|
import {
|
|
CHECK_IN_BADGES,
|
|
CHECK_IN_POINTS,
|
|
cloneCheckInProfile,
|
|
createCheckInRecordId,
|
|
} from './check-in'
|
|
|
|
const badgeCodes = new Set<CheckInBadgeCode>(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<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function hasOnlyKeys(value: Record<string, unknown>, allowedKeys: ReadonlySet<string>): 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 })
|
|
}
|