Skip to content

Commit

Permalink
Merge pull request #45 from Prodeko/analytics
Browse files Browse the repository at this point in the history
Analytics
  • Loading branch information
nlinnanen authored May 4, 2024
2 parents 84230c6 + 8ef2f41 commit 33ddb11
Show file tree
Hide file tree
Showing 10 changed files with 302 additions and 39 deletions.
33 changes: 33 additions & 0 deletions prisma/migrations/20240504135407_add_points_to_entry/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 11 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
117 changes: 117 additions & 0 deletions src/analytics/statistics.ts
Original file line number Diff line number Diff line change
@@ -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<Statistics> {
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<Guild, TeamStatistics>();

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;
}
2 changes: 1 addition & 1 deletion src/commands/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export type EntryWithoutId = {
sport: Sport;
userId: number;
doublePoints: boolean;
earnedPoints: number;
sportMultiplier: number;
};

export type Entry = EntryWithoutId & {
Expand Down Expand Up @@ -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<Guild, TeamStatistics>;

export type pointsPerGuild = Map<Guild, number>;

export type PrivacyState = "accepted" | "rejected";
24 changes: 22 additions & 2 deletions src/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Partial<Entry>>();

Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
};
Expand Down
30 changes: 3 additions & 27 deletions src/launchBotDependingOnNodeEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,27 @@ 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<Context<Update>>) {
launchServer();
await bot.launch();
}

/**
* Launch bot in webhook (production) mode
*/
async function launchWebhookBot(bot: Telegraf<Context<Update>>) {
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();
}

/**
Expand Down
40 changes: 40 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import express from "express";
import { saveEntriesAsCSV } from "../entries";
import statisticsRouter from "./statistics/routes";

// biome-ignore lint/style/noNonNullAssertion: <explanation>
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;
30 changes: 30 additions & 0 deletions src/server/statistics/middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading

0 comments on commit 33ddb11

Please sign in to comment.