diff --git a/backend/src/index.ts b/backend/src/index.ts index f5f5a7da..4283a243 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import { beacon } from "./analytics.js"; import { graniteLink } from "./granite.js"; import { search } from "./spotify.js"; import { getAudio, getImage } from "./storage.js"; +import { calculateStreak } from "./streak.js"; const app = express(); initializeApp({ @@ -69,6 +70,7 @@ app.post("/getImage", getImage); app.post("/getAudio", getAudio); app.post("/gap", gapFund); +app.post("/streak", calculateStreak); app.post("/spotify/search", search); app.use(Sentry.Handlers.errorHandler()); diff --git a/backend/src/streak.ts b/backend/src/streak.ts new file mode 100644 index 00000000..be547aaf --- /dev/null +++ b/backend/src/streak.ts @@ -0,0 +1,71 @@ +import { getDatabase } from "firebase-admin/database"; +import { AnyMap, UserRequest, validateKeys } from "./helpers.js"; +import { Response } from "express"; +import { DateTime } from "luxon"; +import AES from "crypto-js/aes.js"; +import aesutf8 from "crypto-js/enc-utf8.js"; + +const FETCH_LIMIT = 100; + +export const calculateStreak = async (req: UserRequest, res: Response) => { + const db = getDatabase(); + const data = req.body; + const encryptionKey = await validateKeys(data.keys, db, req.user!.user_id); + + if (!encryptionKey) { + res.sendStatus(400); + return; + } + + const today = DateTime.fromISO(data.currentDate); + if (!data.currentDate || typeof data.currentDate !== "string" || !today.isValid) { + res.sendStatus(400); + return; + } + + const logRef = db.ref(req.user!.user_id + "/logs").orderByKey(); + let logs: AnyMap = await (await logRef.limitToLast(FETCH_LIMIT).get()).val(); + if (!logs || Object.keys(logs).length === 0) { + res.send({ streak: 0, danger: false }); + return; + } + + let latestLog: AnyMap = JSON.parse(AES.decrypt(logs[Object.keys(logs).at(-1)!].data, encryptionKey).toString(aesutf8)); + let top = DateTime.fromObject({ year: latestLog.year, month: latestLog.month, day: latestLog.day }); + + // If the top log is not today or yesterday, the streak is 0 + if (top.toISODate() !== today.toISODate() && top.toISODate() !== today.minus({ days: 1 }).toISODate()) { + res.send({ streak: 0, danger: false }); + return; + } + // If the top log is yesterday, the user is in danger of losing their streak. + let danger = top.toISODate() === today.minus({ days: 1 }).toISODate(); + + let streak = 1; + let running = true; + while (running && Object.keys(logs).length > 0) { + // Same general logic as `calculateStreak` in the frontend + // (max change of one day to continue streak) + const logKeys = Object.keys(logs).reverse(); + for (const key of logKeys) { + const log = JSON.parse(AES.decrypt(logs[key].data, encryptionKey).toString(aesutf8)); + if (top.day !== log.day || top.month !== log.month || top.year !== log.year) { + const logDT = DateTime.fromObject({ year: log.year, month: log.month, day: log.day }); + if (logDT.toISODate() === top.minus({ days: 1 }).toISODate()) { + top = logDT; + ++streak; + } else { + running = false; + break; + } + } + } + + // If the streak is going and we've run out of logs, try to fetch more + if (running) { + logs = await (await logRef.endBefore(Object.keys(logs)[0]).limitToLast(FETCH_LIMIT).get()).val(); + } + } + + res.send({ streak, danger }); +}; \ No newline at end of file