1245 lines
26 KiB
JavaScript
1245 lines
26 KiB
JavaScript
const {
|
|
getProfileCollections,
|
|
getProfileStats,
|
|
achievementRequirementsMet,
|
|
isStatBackfillableAchievement
|
|
} = require("./profileSystem");
|
|
|
|
const {
|
|
getUnixTime
|
|
} = require("./cookieEconomy");
|
|
|
|
|
|
function getActiveDefinitions(
|
|
achievementConfig
|
|
) {
|
|
return achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray();
|
|
}
|
|
|
|
|
|
async function reconcileUserAchievements({
|
|
guildId,
|
|
userId,
|
|
dryRun = false
|
|
}) {
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const [
|
|
statsDocument,
|
|
definitions,
|
|
achievementDocument
|
|
] = await Promise.all([
|
|
getProfileStats(
|
|
userId,
|
|
guildId
|
|
),
|
|
|
|
getActiveDefinitions(
|
|
achievementConfig
|
|
),
|
|
|
|
achievements.findOne({
|
|
userId,
|
|
guildId
|
|
})
|
|
]);
|
|
|
|
const unlocked = {
|
|
...(achievementDocument?.unlocked ?? {})
|
|
};
|
|
|
|
const additions = {};
|
|
|
|
let skippedEventOnly = 0;
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* NORMAL / STAT-BACKED ACHIEVEMENTS
|
|
* ========================================================
|
|
*/
|
|
|
|
const normalDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure !==
|
|
"achievementPercentage"
|
|
);
|
|
|
|
|
|
for (const definition of normalDefinitions) {
|
|
if (
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!isStatBackfillableAchievement(
|
|
definition
|
|
)
|
|
) {
|
|
skippedEventOnly++;
|
|
continue;
|
|
}
|
|
|
|
|
|
if (
|
|
!achievementRequirementsMet(
|
|
statsDocument,
|
|
definition
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
|
|
const timestamp =
|
|
getUnixTime();
|
|
|
|
|
|
additions[
|
|
definition.achievementId
|
|
] = timestamp;
|
|
|
|
|
|
unlocked[
|
|
definition.achievementId
|
|
] = timestamp;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* COLLECTOR ACHIEVEMENTS
|
|
* ========================================================
|
|
*
|
|
* Only stat-backed achievements count toward the
|
|
* collectible denominator.
|
|
*
|
|
* This intentionally matches profileSystem.js.
|
|
*/
|
|
|
|
const collectibleDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
isStatBackfillableAchievement(
|
|
definition
|
|
)
|
|
);
|
|
|
|
|
|
const unlockedCollectible =
|
|
collectibleDefinitions.filter(
|
|
definition =>
|
|
Boolean(
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
|
|
const percentage =
|
|
collectibleDefinitions.length > 0
|
|
? unlockedCollectible /
|
|
collectibleDefinitions.length
|
|
: 0;
|
|
|
|
|
|
const collectorDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure ===
|
|
"achievementPercentage"
|
|
);
|
|
|
|
|
|
for (const definition of collectorDefinitions) {
|
|
if (
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
|
|
if (
|
|
percentage <
|
|
Number(
|
|
definition.target ?? 0
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
|
|
const timestamp =
|
|
getUnixTime();
|
|
|
|
|
|
additions[
|
|
definition.achievementId
|
|
] = timestamp;
|
|
|
|
|
|
unlocked[
|
|
definition.achievementId
|
|
] = timestamp;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* WRITE
|
|
* ========================================================
|
|
*/
|
|
|
|
const additionEntries =
|
|
Object.entries(
|
|
additions
|
|
);
|
|
|
|
|
|
if (
|
|
!dryRun &&
|
|
additionEntries.length > 0
|
|
) {
|
|
const set = {};
|
|
|
|
for (
|
|
const [
|
|
achievementId,
|
|
timestamp
|
|
]
|
|
of additionEntries
|
|
) {
|
|
set[
|
|
`unlocked.${achievementId}`
|
|
] = timestamp;
|
|
}
|
|
|
|
await achievements.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$setOnInsert: {
|
|
userId,
|
|
guildId
|
|
},
|
|
|
|
$set: set
|
|
},
|
|
{
|
|
upsert: true
|
|
}
|
|
);
|
|
|
|
console.log(
|
|
`[ProfileMaintenance] Reconciled ${additionEntries.length} achievement(s) for ${userId}: ${additionEntries
|
|
.map(
|
|
([achievementId]) =>
|
|
achievementId
|
|
)
|
|
.join(", ")}`
|
|
);
|
|
}
|
|
|
|
|
|
if (
|
|
dryRun &&
|
|
additionEntries.length > 0
|
|
) {
|
|
console.log(
|
|
`[ProfileMaintenance] Dry run: ${userId} would unlock ${additionEntries.length} achievement(s): ${additionEntries
|
|
.map(
|
|
([achievementId]) =>
|
|
achievementId
|
|
)
|
|
.join(", ")}`
|
|
);
|
|
}
|
|
|
|
|
|
return {
|
|
userId,
|
|
|
|
checked:
|
|
definitions.length,
|
|
|
|
unlocked:
|
|
additionEntries.length,
|
|
|
|
skippedEventOnly,
|
|
|
|
dryRun,
|
|
|
|
achievementIds:
|
|
additionEntries.map(
|
|
([achievementId]) =>
|
|
achievementId
|
|
)
|
|
};
|
|
}
|
|
|
|
|
|
async function reconcileAllAchievements({
|
|
guildId,
|
|
dryRun = false
|
|
}) {
|
|
const {
|
|
stats
|
|
} = getProfileCollections();
|
|
|
|
|
|
const userIds =
|
|
await stats.distinct(
|
|
"userId",
|
|
{
|
|
guildId
|
|
}
|
|
);
|
|
|
|
|
|
const summary = {
|
|
users:
|
|
userIds.length,
|
|
|
|
unlocked:
|
|
0,
|
|
|
|
skippedEventOnly:
|
|
0,
|
|
|
|
results: []
|
|
};
|
|
|
|
|
|
for (const userId of userIds) {
|
|
const result =
|
|
await reconcileUserAchievements({
|
|
guildId,
|
|
userId,
|
|
dryRun
|
|
});
|
|
|
|
|
|
summary.unlocked +=
|
|
result.unlocked;
|
|
|
|
|
|
summary.skippedEventOnly +=
|
|
result.skippedEventOnly;
|
|
|
|
|
|
summary.results.push(
|
|
result
|
|
);
|
|
}
|
|
|
|
|
|
return summary;
|
|
}
|
|
|
|
|
|
async function auditProfileData(
|
|
guildId
|
|
) {
|
|
const {
|
|
stats,
|
|
achievements,
|
|
achievementConfig,
|
|
badges
|
|
} = getProfileCollections();
|
|
|
|
|
|
const [
|
|
statDocuments,
|
|
achievementDocuments,
|
|
activeDefinitions,
|
|
badgeDocuments
|
|
] = await Promise.all([
|
|
stats.find({
|
|
guildId
|
|
}).toArray(),
|
|
|
|
achievements.find({
|
|
guildId
|
|
}).toArray(),
|
|
|
|
achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray(),
|
|
|
|
badges.find({
|
|
guildId
|
|
}).toArray()
|
|
]);
|
|
|
|
|
|
const issues = [];
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* STATS DOCUMENT VALIDATION
|
|
* ========================================================
|
|
*/
|
|
|
|
for (const document of statDocuments) {
|
|
if (!document.userId) {
|
|
issues.push(
|
|
"Stats document missing userId."
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* GAME TOTALS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
const total =
|
|
document.games?.total ?? {};
|
|
|
|
|
|
const played =
|
|
Number(
|
|
total.played ?? 0
|
|
);
|
|
|
|
|
|
const resolved =
|
|
Number(
|
|
total.won ?? 0
|
|
)
|
|
+
|
|
Number(
|
|
total.lost ?? 0
|
|
)
|
|
+
|
|
Number(
|
|
total.pushed ?? 0
|
|
);
|
|
|
|
|
|
if (
|
|
played !==
|
|
resolved
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: games.total.played (${played}) does not match W/L/P total (${resolved}).`
|
|
);
|
|
}
|
|
|
|
|
|
for (
|
|
const game
|
|
of [
|
|
"slots",
|
|
"blackjack",
|
|
"roulette",
|
|
"heist"
|
|
]
|
|
) {
|
|
const value =
|
|
document.games?.[game] ?? {};
|
|
|
|
|
|
const gamePlayed =
|
|
Number(
|
|
value.played ?? 0
|
|
);
|
|
|
|
|
|
const gameResolved =
|
|
Number(
|
|
value.won ?? 0
|
|
)
|
|
+
|
|
Number(
|
|
value.lost ?? 0
|
|
)
|
|
+
|
|
Number(
|
|
value.pushed ?? 0
|
|
);
|
|
|
|
|
|
if (
|
|
gamePlayed !==
|
|
gameResolved
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: games.${game}.played (${gamePlayed}) does not match W/L/P total (${gameResolved}).`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* NON-NEGATIVE ECONOMY COUNTERS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
const nonNegativeFields = [
|
|
document.economy
|
|
?.cookiesObtainedTotal,
|
|
|
|
document.economy
|
|
?.cookiesLostTotal,
|
|
|
|
document.economy
|
|
?.cookiesGivenTotal,
|
|
|
|
document.economy
|
|
?.stolenTotal,
|
|
|
|
document.economy
|
|
?.stolenFromMeTotal,
|
|
|
|
document.economy
|
|
?.vaultStolenTotal
|
|
];
|
|
|
|
|
|
if (
|
|
nonNegativeFields.some(
|
|
value =>
|
|
Number(
|
|
value ?? 0
|
|
) < 0
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: one or more economy counters are negative.`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* ACTIVITY SCHEMA
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
const activity =
|
|
document.activity;
|
|
|
|
|
|
if (!activity) {
|
|
issues.push(
|
|
`${document.userId}: activity object is missing.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
const currentDailyStreak =
|
|
Number(
|
|
activity.currentDailyStreak ?? 0
|
|
);
|
|
|
|
|
|
const bestDailyStreak =
|
|
Number(
|
|
activity.bestDailyStreak ?? 0
|
|
);
|
|
|
|
|
|
if (
|
|
currentDailyStreak < 0 ||
|
|
bestDailyStreak < 0
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity streak counters cannot be negative.`
|
|
);
|
|
}
|
|
|
|
|
|
if (
|
|
currentDailyStreak >
|
|
bestDailyStreak
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: currentDailyStreak (${currentDailyStreak}) exceeds bestDailyStreak (${bestDailyStreak}).`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* DATE KEYS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
for (
|
|
const field
|
|
of [
|
|
"lastActiveDate",
|
|
"lastGameDate"
|
|
]
|
|
) {
|
|
const value =
|
|
activity[field];
|
|
|
|
|
|
if (
|
|
value !== null &&
|
|
value !== undefined &&
|
|
!isValidDateKey(
|
|
value
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.${field} has invalid date key "${value}".`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* WEEK KEY
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
if (
|
|
activity.currentWeekKey !==
|
|
null
|
|
&&
|
|
activity.currentWeekKey !==
|
|
undefined
|
|
&&
|
|
!isValidWeekKey(
|
|
activity.currentWeekKey
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.currentWeekKey has invalid value "${activity.currentWeekKey}".`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* WEEKDAY COUNTERS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
const gamesByWeekday =
|
|
activity.gamesByWeekday ?? {};
|
|
|
|
|
|
const weekdays = [
|
|
"monday",
|
|
"tuesday",
|
|
"wednesday",
|
|
"thursday",
|
|
"friday",
|
|
"saturday",
|
|
"sunday"
|
|
];
|
|
|
|
|
|
for (const weekday of weekdays) {
|
|
const value =
|
|
Number(
|
|
gamesByWeekday[
|
|
weekday
|
|
] ?? 0
|
|
);
|
|
|
|
|
|
if (
|
|
!Number.isFinite(
|
|
value
|
|
)
|
|
||
|
|
value < 0
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.gamesByWeekday.${weekday} is invalid.`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* CURRENT WEEK DAYS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
const currentWeekDays =
|
|
activity.currentWeekDays;
|
|
|
|
|
|
if (
|
|
!Array.isArray(
|
|
currentWeekDays
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.currentWeekDays is not an array.`
|
|
);
|
|
}
|
|
|
|
else {
|
|
const uniqueDays =
|
|
new Set(
|
|
currentWeekDays
|
|
);
|
|
|
|
|
|
if (
|
|
uniqueDays.size !==
|
|
currentWeekDays.length
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.currentWeekDays contains duplicate weekdays.`
|
|
);
|
|
}
|
|
|
|
|
|
for (
|
|
const weekday
|
|
of currentWeekDays
|
|
) {
|
|
if (
|
|
!weekdays.includes(
|
|
weekday
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.currentWeekDays contains invalid weekday "${weekday}".`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* TIME BUCKETS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
const timeBuckets =
|
|
activity.gamesByTimeBucket ?? {};
|
|
|
|
|
|
for (
|
|
const bucket
|
|
of [
|
|
"overnight",
|
|
"morning",
|
|
"afternoon",
|
|
"evening"
|
|
]
|
|
) {
|
|
const value =
|
|
Number(
|
|
timeBuckets[
|
|
bucket
|
|
] ?? 0
|
|
);
|
|
|
|
|
|
if (
|
|
!Number.isFinite(
|
|
value
|
|
)
|
|
||
|
|
value < 0
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: activity.gamesByTimeBucket.${bucket} is invalid.`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* SCHEMA VERSION
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
if (
|
|
Number(
|
|
document
|
|
.profileSchemaVersion ?? 0
|
|
) < 2
|
|
) {
|
|
issues.push(
|
|
`${document.userId}: profileSchemaVersion is below 2.`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ACHIEVEMENT DEFINITION VALIDATION
|
|
* ========================================================
|
|
*/
|
|
|
|
const achievementIds =
|
|
new Set();
|
|
|
|
|
|
for (
|
|
const definition
|
|
of activeDefinitions
|
|
) {
|
|
/*
|
|
* ----------------------------------------------------
|
|
* ACHIEVEMENT ID
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
if (
|
|
!definition
|
|
.achievementId
|
|
) {
|
|
issues.push(
|
|
"Active achievement definition missing achievementId."
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
if (
|
|
achievementIds.has(
|
|
definition.achievementId
|
|
)
|
|
) {
|
|
issues.push(
|
|
`Duplicate active achievementId: ${definition.achievementId}.`
|
|
);
|
|
}
|
|
|
|
|
|
achievementIds.add(
|
|
definition.achievementId
|
|
);
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* COLLECTOR ACHIEVEMENTS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
if (
|
|
definition.measure ===
|
|
"achievementPercentage"
|
|
) {
|
|
if (
|
|
definition.target ===
|
|
undefined
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: target is missing.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
const target =
|
|
Number(
|
|
definition.target
|
|
);
|
|
|
|
|
|
if (
|
|
!Number.isFinite(
|
|
target
|
|
)
|
|
||
|
|
target < 0
|
|
||
|
|
target > 1
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: achievementPercentage target must be between 0 and 1.`
|
|
);
|
|
}
|
|
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* COMPOSITE ACHIEVEMENTS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
if (
|
|
definition.measure ===
|
|
"all"
|
|
||
|
|
definition.measure ===
|
|
"any"
|
|
) {
|
|
if (
|
|
!Array.isArray(
|
|
definition.conditions
|
|
)
|
|
||
|
|
definition.conditions.length ===
|
|
0
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: composite achievement has no conditions.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
const validOperators = [
|
|
"eq",
|
|
"neq",
|
|
"gt",
|
|
"gte",
|
|
"lt",
|
|
"lte",
|
|
"includes",
|
|
"not_includes",
|
|
"truthy",
|
|
"falsy"
|
|
];
|
|
|
|
|
|
for (
|
|
const [
|
|
index,
|
|
condition
|
|
]
|
|
of definition
|
|
.conditions
|
|
.entries()
|
|
) {
|
|
if (
|
|
!condition
|
|
||
|
|
typeof condition !==
|
|
"object"
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: condition ${index + 1} is invalid.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
if (
|
|
!condition.stat
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: condition ${index + 1} has no stat path.`
|
|
);
|
|
}
|
|
|
|
|
|
const operator =
|
|
String(
|
|
condition.operator ??
|
|
"gte"
|
|
).toLowerCase();
|
|
|
|
|
|
if (
|
|
!validOperators.includes(
|
|
operator
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: condition ${index + 1} has unknown operator "${operator}".`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Operators that compare against a value
|
|
* require condition.value.
|
|
*/
|
|
|
|
if (
|
|
[
|
|
"eq",
|
|
"neq",
|
|
"gt",
|
|
"gte",
|
|
"lt",
|
|
"lte",
|
|
"includes",
|
|
"not_includes"
|
|
].includes(
|
|
operator
|
|
)
|
|
&&
|
|
condition.value ===
|
|
undefined
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: condition ${index + 1} using "${operator}" is missing value.`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Numeric operators should use numeric
|
|
* comparison values.
|
|
*/
|
|
|
|
if (
|
|
[
|
|
"gt",
|
|
"gte",
|
|
"lt",
|
|
"lte"
|
|
].includes(
|
|
operator
|
|
)
|
|
&&
|
|
!Number.isFinite(
|
|
Number(
|
|
condition.value
|
|
)
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: condition ${index + 1} using "${operator}" has a non-numeric value.`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* Composite achievements use their conditions
|
|
* instead of a top-level stat/target.
|
|
*/
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* NORMAL STAT-BACKED ACHIEVEMENTS
|
|
* ----------------------------------------------------
|
|
*/
|
|
|
|
if (
|
|
definition.stat
|
|
) {
|
|
if (
|
|
definition.target ===
|
|
undefined
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: target is missing.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
const target =
|
|
Number(
|
|
definition.target
|
|
);
|
|
|
|
|
|
if (
|
|
!Number.isFinite(
|
|
target
|
|
)
|
|
) {
|
|
issues.push(
|
|
`${definition.achievementId}: target is not numeric.`
|
|
);
|
|
}
|
|
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
/*
|
|
* ----------------------------------------------------
|
|
* EVENT-ONLY ACHIEVEMENTS
|
|
* ----------------------------------------------------
|
|
*
|
|
* These aren't considered structurally invalid.
|
|
*
|
|
* Stage 4 will introduce proper event-condition
|
|
* validation for them.
|
|
*/
|
|
|
|
continue;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ORPHANED ACHIEVEMENT DOCUMENTS
|
|
* ========================================================
|
|
*/
|
|
|
|
const statUsers =
|
|
new Set(
|
|
statDocuments.map(
|
|
document =>
|
|
document.userId
|
|
)
|
|
);
|
|
|
|
|
|
const orphanAchievementDocs =
|
|
achievementDocuments.filter(
|
|
document =>
|
|
!statUsers.has(
|
|
document.userId
|
|
)
|
|
).length;
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* BADGES
|
|
* ========================================================
|
|
*/
|
|
|
|
const claimedBadges =
|
|
badgeDocuments.filter(
|
|
badge =>
|
|
Boolean(
|
|
badge.ownerId
|
|
)
|
|
).length;
|
|
|
|
|
|
return {
|
|
statDocuments:
|
|
statDocuments.length,
|
|
|
|
achievementDocuments:
|
|
achievementDocuments.length,
|
|
|
|
activeAchievements:
|
|
activeDefinitions.length,
|
|
|
|
badgeDocuments:
|
|
badgeDocuments.length,
|
|
|
|
claimedBadges,
|
|
|
|
orphanAchievementDocs,
|
|
|
|
issueCount:
|
|
issues.length,
|
|
|
|
issues
|
|
};
|
|
}
|
|
|
|
|
|
function isValidDateKey(
|
|
value
|
|
) {
|
|
if (
|
|
typeof value !==
|
|
"string"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
|
|
if (
|
|
!/^\d{4}-\d{2}-\d{2}$/.test(
|
|
value
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
|
|
const date =
|
|
new Date(
|
|
`${value}T00:00:00Z`
|
|
);
|
|
|
|
|
|
if (
|
|
Number.isNaN(
|
|
date.getTime()
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
|
|
return (
|
|
date
|
|
.toISOString()
|
|
.slice(
|
|
0,
|
|
10
|
|
)
|
|
===
|
|
value
|
|
);
|
|
}
|
|
|
|
|
|
function isValidWeekKey(
|
|
value
|
|
) {
|
|
return (
|
|
typeof value ===
|
|
"string"
|
|
&&
|
|
/^\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3])$/.test(
|
|
value
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
reconcileUserAchievements,
|
|
reconcileAllAchievements,
|
|
auditProfileData
|
|
}; |