diff --git a/prisma/migrations/20240504135407_add_points_to_entry/migration.sql b/prisma/migrations/20240504135407_add_points_to_entry/migration.sql new file mode 100644 index 0000000..8ae4a9f --- /dev/null +++ b/prisma/migrations/20240504135407_add_points_to_entry/migration.sql @@ -0,0 +1,33 @@ +/* + Warnings: + + - Added the required column `earnedPoints` to the `Entry` table without a default value. This is not possible if the table is not empty. + - Added the required column `sportMultiplier` to the `Entry` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Entry" ADD COLUMN "sportMultiplier" DOUBLE PRECISION; + +UPDATE "Entry" +SET "sportMultiplier" = + CASE + WHEN "sport" = 'swim' THEN 5 + WHEN "sport" IN ('run', 'walk') THEN 1 + WHEN "sport" IN ('ski', 'rollerski', 'rollerblade', 'skateboard') THEN 0.5 + WHEN "sport" = 'cycle' THEN 0.2 + END; + +ALTER TABLE "Entry" +ALTER COLUMN "sportMultiplier" SET NOT NULL; + +ALTER TABLE "Entry" ADD COLUMN "earnedPoints" DOUBLE PRECISION; + +UPDATE "Entry" +SET "earnedPoints" = + CASE + WHEN "doublePoints" THEN "distance" * "sportMultiplier" * 2 + ELSE "distance" * "sportMultiplier" + END; + +ALTER TABLE "Entry" +ALTER COLUMN "earnedPoints" SET NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 554f760..2bba04a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,13 +23,15 @@ model User { } model Entry { - id Int @id @default(autoincrement()) - distance Float - fileId String - sport String - user User @relation(fields: [userId], references: [telegramUserId]) - doublePoints Boolean @default(false) - userId BigInt - createdAt DateTime @default(now()) - valid Boolean? + id Int @id @default(autoincrement()) + distance Float + fileId String + sport String + user User @relation(fields: [userId], references: [telegramUserId]) + doublePoints Boolean @default(false) + userId BigInt + createdAt DateTime @default(now()) + valid Boolean? + sportMultiplier Float + earnedPoints Float } diff --git a/src/analytics/statistics.ts b/src/analytics/statistics.ts new file mode 100644 index 0000000..183f383 --- /dev/null +++ b/src/analytics/statistics.ts @@ -0,0 +1,117 @@ +import { prisma } from "../../config"; +import type { Guild, Statistics, TeamStatistics } from "../common/types"; + +interface PeriodStats { + guild: Guild; + pointsGainedInPeriod: number; + continuingParticipants: number; + proportionOfContinuingParticipants: number; +} + +interface Aggregate { + guild: Guild; + totalPoints: number; + totalKilometers: number; + totalEntries: number; + numberOfUniqueParticipants: number; + milestoneAchievers: number; + proportionOfMilestoneAchievers: number; +} + +// Function to calculate and return the total points +export async function calculateGuildStatistics( + periodStart: Date, + periodEnd: Date, +): Promise { + const aggregates = (await prisma.$queryRaw` + SELECT + guild, + SUM("earnedPoints") as "totalPoints", + SUM(distance) as "totalKilometers", + COUNT(*) as "totalEntries", + COUNT(DISTINCT user) as "numberOfUniqueParticipants", + COUNT(CASE WHEN "earnedPoints" >= 50 THEN 1 END)/COUNT(DISTINCT user) as "proportionOfMilestoneAchievers" + FROM + "Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" + GROUP BY + guild + `) as Aggregate[]; + + const periodStats = (await prisma.$queryRaw` + WITH period_stats AS ( + SELECT + guild, + SUM("earnedPoints") as "pointsGainedInPeriod", + COUNT(DISTINCT user) as "continuingParticipants" + FROM + "Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" + WHERE + "Entry"."createdAt" BETWEEN ${periodStart} AND ${periodEnd} + GROUP BY + guild + ), + previous_users AS ( + SELECT + guild, + COUNT(DISTINCT user) as "previousParticipants" + FROM + "Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" + WHERE + "Entry"."createdAt" < ${periodStart} + GROUP BY + guild + ) + + SELECT + period_stats.guild, + "pointsGainedInPeriod", + CASE WHEN "previousParticipants" = 0 THEN 0 ELSE "continuingParticipants"/"previousParticipants" END as "proportionOfContinuingParticipants" + FROM + period_stats LEFT JOIN previous_users ON period_stats.guild = previous_users.guild + `) as PeriodStats[]; + + const entriesInPeriod = await prisma.entry.findMany({ + where: { + createdAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + }); + + const previousUsers = (await prisma.$queryRaw` + SELECT + guild, + COUNT(DISTINCT user) as "previousParticipants" + FROM + "Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" + WHERE + "Entry"."createdAt" < ${periodStart} + GROUP BY + guild + `) as { guild: Guild; previousParticipants: number }[]; + + console.log(entriesInPeriod); + const statistics = new Map(); + + for (const aggregate of aggregates) { + const periodStat = periodStats.find( + (stat) => stat.guild === aggregate.guild, + ); + console.log("period", periodStats); + statistics.set(aggregate.guild, { + totalPoints: aggregate.totalPoints, + totalKilometers: aggregate.totalKilometers, + totalEntries: Number(aggregate.totalEntries), + numberOfUniqueParticipants: Number(aggregate.numberOfUniqueParticipants), + proportionOfContinuingParticipants: + Number(periodStat?.proportionOfContinuingParticipants) || 0, + pointsGainedInPeriod: Number(periodStat?.pointsGainedInPeriod) || 0, + proportionOfMilestoneAchievers: Number( + aggregate.proportionOfMilestoneAchievers, + ), + }); + } + + return statistics; +} diff --git a/src/commands/entries.ts b/src/commands/entries.ts index 2d3fa1a..273785f 100644 --- a/src/commands/entries.ts +++ b/src/commands/entries.ts @@ -15,7 +15,7 @@ const entries = async ( if (entries.length > 0) { const validEntries = entries.filter((e) => e.valid !== false); const points = validEntries - .map((e) => e.distance * COEFFICIENTS[e.sport] * (e.doublePoints ? 2 : 1)) + .map((e) => e.earnedPoints) .reduce((p, e) => p + e, 0); const distance = validEntries.reduce((p, e) => p + e.distance, 0); diff --git a/src/common/types.ts b/src/common/types.ts index ec53317..ca68093 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -33,6 +33,8 @@ export type EntryWithoutId = { sport: Sport; userId: number; doublePoints: boolean; + earnedPoints: number; + sportMultiplier: number; }; export type Entry = EntryWithoutId & { @@ -74,4 +76,18 @@ export type PhotoCtxType = NarrowedContext< } >; +export type TeamStatistics = { + totalPoints: number; + totalKilometers: number; + totalEntries: number; + numberOfUniqueParticipants: number; + proportionOfContinuingParticipants: number; + pointsGainedInPeriod: number; + proportionOfMilestoneAchievers: number; +}; + +export type Statistics = Map; + +export type pointsPerGuild = Map; + export type PrivacyState = "accepted" | "rejected"; diff --git a/src/entries.ts b/src/entries.ts index 769d408..ce688e4 100644 --- a/src/entries.ts +++ b/src/entries.ts @@ -4,6 +4,7 @@ import { prisma } from "../config"; import type { Entry, EntryWithUser } from "./common/types"; import { arrayToCSV } from "./common/utils"; import { isBigInteger, isCompleteEntry } from "./common/validators"; +import { COEFFICIENTS } from "./common/constants"; const entries = new Map>(); @@ -62,7 +63,17 @@ const setEntryValidation = async (entryId: number, valid: boolean) => { }; const setEntryDoublePoints = async (entryId: number, doublePoints: boolean) => { - await prisma.entry.update({ where: { id: entryId }, data: { doublePoints } }); + const oldEntry = await prisma.entry.findUniqueOrThrow({ + where: { id: entryId }, + }); + await prisma.entry.update({ + where: { id: entryId }, + data: { + doublePoints, + earnedPoints: + oldEntry.distance * oldEntry.sportMultiplier * (doublePoints ? 2 : 1), + }, + }); }; const removeLatest = async (userId: number) => { @@ -86,8 +97,17 @@ const entryToDb = async (chatId: number) => { if (!entry || !isCompleteEntry(entry)) throw new Error("Entry is not complete!"); + const sportMultiplier = COEFFICIENTS[entry.sport]; + await prisma.entry.create({ - data: entry, + data: { + ...entry, + sportMultiplier, + earnedPoints: + entry.distance * + sportMultiplier * + (entry.doublePoints ?? false ? 2 : 1), + }, }); entries.delete(chatId); }; diff --git a/src/launchBotDependingOnNodeEnv.ts b/src/launchBotDependingOnNodeEnv.ts index b8a62a6..67c93e9 100644 --- a/src/launchBotDependingOnNodeEnv.ts +++ b/src/launchBotDependingOnNodeEnv.ts @@ -4,11 +4,13 @@ import type { Context, Telegraf } from "telegraf"; import type { Update } from "telegraf/typings/core/types/typegram"; import { saveEntriesAsCSV } from "./entries"; +import app, { launchServer } from "./server"; /** * Launch bot in long polling (development) mode */ async function launchLongPollBot(bot: Telegraf>) { + launchServer(); await bot.launch(); } @@ -16,39 +18,13 @@ async function launchLongPollBot(bot: Telegraf>) { * Launch bot in webhook (production) mode */ async function launchWebhookBot(bot: Telegraf>) { - const port = Number.parseInt(process.env.PORT!); - - // Necessary because of Azure App Service health check on startup - const app = express(); - - app.get("/", (_req, res) => { - res.status(200).send("Kovaa tulee"); - }); - - app.get("/health", (_req, res) => { - res.status(200).send("OK"); - }); - - app.get("/entries", async (req, res) => { - if (req.query.pass !== process.env.ADMIN_PASSWORD) { - console.log("Wrong password"); - return res.status(401).send("Wrong password!"); - } - console.log("here"); - await saveEntriesAsCSV(); - res.attachment("./entries.csv"); - res.header("Content-Type", "text/csv"); - res.status(200).send(fs.readFileSync("./entries.csv")); - }); - // Workaround to avoid issue with TSconfig const createWebhookListener = async () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion app.use(await bot.createWebhook({ domain: process.env.DOMAIN! })); }; await createWebhookListener(); - - app.listen(port, () => console.log("Running on port ", port)); + launchServer(); } /** diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..c5922b8 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,40 @@ +import express from "express"; +import { saveEntriesAsCSV } from "../entries"; +import statisticsRouter from "./statistics/routes"; + +// biome-ignore lint/style/noNonNullAssertion: +const port = Number.parseInt(process.env.PORT!); + +const app = express(); + +// Necessary because of Azure App Service health check on startup +app.get("/", (_req, res) => { + res.status(200).send("Kovaa tulee"); +}); + +app.get("/health", (_req, res) => { + res.status(200).send("OK"); +}); + +app.use(async (req, res, next) => { + if (req.query.pass !== process.env.ADMIN_PASSWORD) { + console.log("Wrong password"); + return res.status(401).send("Wrong password!"); + } + next(); +}) + +app.get("/entries", async (req, res) => { + await saveEntriesAsCSV(); + res.attachment("./entries.csv"); + res.header("Content-Type", "text/csv"); + res.status(200).send(fs.readFileSync("./entries.csv")); +}); + +app.use("/statistics", statisticsRouter); + +export const launchServer = async () => { + app.listen(port, () => console.log("Running on port ", port)); +} + +export default app; \ No newline at end of file diff --git a/src/server/statistics/middleware.ts b/src/server/statistics/middleware.ts new file mode 100644 index 0000000..5a94afc --- /dev/null +++ b/src/server/statistics/middleware.ts @@ -0,0 +1,30 @@ +import type { NextFunction, Request, Response } from "express"; + +export const validatePeriod = (req: Request, res: Response, next: NextFunction) => { + const start = req.query.start; + const end = req.query.end; + + if (!start || !end) { + return res.status(400).send('Period start and end query parameters are required'); + } + + if (typeof start !== 'string' || typeof end !== 'string') { + return res.status(400).send('Invalid period start and end query parameters'); + } + + if (Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) { + return res.status(400).send('Period start and end query parameters must be valid dates'); + } + + const startDate = new Date(start); + const endDate = new Date(end); + + if (startDate > endDate) { + return res.status(400).send('Period start must be before period end'); + } + + res.locals.periodStart = startDate; + res.locals.periodEnd = endDate; + + next(); +} \ No newline at end of file diff --git a/src/server/statistics/routes.ts b/src/server/statistics/routes.ts new file mode 100644 index 0000000..7fa1c6e --- /dev/null +++ b/src/server/statistics/routes.ts @@ -0,0 +1,29 @@ +import express from 'express'; +import { validatePeriod } from './middleware'; +import { calculateGuildStatistics } from '../../analytics/statistics'; + +const router = express.Router({mergeParams: true}); + +export interface StatisticsResponse extends express.Response { + locals: { + guild: string; + periodStart: Date; + periodEnd: Date; + }; +} + +// Middleware to validate the period start and end query parameters +router.use(validatePeriod); + +router.get('/', async (req, res: StatisticsResponse) => { + const { periodStart, periodEnd } = res.locals; + try { + const statistics = await calculateGuildStatistics(periodStart, periodEnd); + res.json(Object.fromEntries(statistics)); + } catch (error) { + console.error(error); + res.status(500).send('An error occurred while calculating statistics'); + } +}); + +export default router;