diff --git a/controllers/challenges.ts b/controllers/challenges.ts index 18bc445..80c2cb1 100644 --- a/controllers/challenges.ts +++ b/controllers/challenges.ts @@ -90,7 +90,8 @@ export default abstract class ChallengesController { .select({ id: challenges.id, name: challenges.name, - score: challenges.score + score: challenges.score, + clubId: challenges.clubId }) .from(challenges) .innerJoin(clubs, eq(challenges.clubId, clubs.id)) diff --git a/controllers/clubs.ts b/controllers/clubs.ts index eff0703..3854f93 100644 --- a/controllers/clubs.ts +++ b/controllers/clubs.ts @@ -62,7 +62,7 @@ export default abstract class ClubController { return club.length ? club[0] : null; } - public static async getDailyClub() { + public static async getDailyClubs() { const club = await DB.instance .select({ avatarUrl: clubs.avatarUrl, @@ -70,10 +70,9 @@ export default abstract class ClubController { description: clubs.description }) .from(clubs) - .where(eq(clubs.dailyDate, new Date())) - .limit(1); + .where(eq(clubs.dailyDate, new Date())); - return club.length ? club[0] : null; + return club; } public static async createClub(name: string, avatarUrl: string, description?: string, dailyDate?: Date) { diff --git a/controllers/leaderboard.ts b/controllers/leaderboard.ts index f9c170d..6029600 100644 --- a/controllers/leaderboard.ts +++ b/controllers/leaderboard.ts @@ -1,104 +1,104 @@ -import DB from "@/database/config"; -import Redis from "@/database/redis"; -import { acquired } from "@/database/schema/acquired"; -import { challenges } from "@/database/schema/challenges"; -import { users } from "@/database/schema/users"; -import Logger from "@/log/logger"; -import { eq, sum } from "drizzle-orm"; -import UserController from "./users"; -import { clubs } from "@/database/schema/clubs"; -import ClubController from "./clubs"; - -export default abstract class LeaderboardController { - public static async getLeaderboardEtag() { - return await Redis.get("leaderboard:etag"); - } - - public static async getUserLeaderboard() { - const leaderboardUuids = await Redis.sortedAll("leaderboard:users"); - const allUsers = await DB.instance - .select({ - uuid: users.uuid, - username: users.username, - quote: users.quote, - avatarUrl: users.avatarUrl - }) - .from(users); - - const usersMap = new Map(allUsers.map((user) => [user.uuid, user])); - return leaderboardUuids.map((set) => ({ - score: set.score, - user: usersMap.get(set.value) - })); - } - - public static async getClubLeaderboard() { - const leaderboardUuids = await Redis.sortedAll(`leaderboard:clubs`); - const allClubs = await ClubController.getAllClubs(); - - const clubsMap = new Map(allClubs.map((club) => [club.id, club])); - return leaderboardUuids.map((set) => ({ - score: set.score, - club: clubsMap.get(set.value) - })); - } - - public static async grant(userUuid: string, challengeId: number) { - // Check if the user exists - const user = await UserController.getUser(userUuid); - if (!user) return false; - - // Create a new acquired record - try { - await DB.instance.insert(acquired).values({ - userUuid: userUuid, - challengeId: challengeId - }); - } catch (error: unknown) { - Logger.error("leaderboard.ts::grant", error); - return false; - } - - const userScoreRequest = await DB.instance - .select({ - score: sum(challenges.score).as("score") - }) - .from(acquired) - .innerJoin(challenges, eq(acquired.challengeId, challenges.id)) - .where(eq(acquired.userUuid, userUuid)); - - if (userScoreRequest.length !== 1) return false; - const userScore = parseInt(userScoreRequest[0].score ?? "0"); - - // Add/update the user's score in the leaderboard - await Redis.sortedSet("leaderboard:users", userScore, userUuid); - - if (user.clubId) { - // Add/update the club's score in the leaderboard - - //! Not sure if this is the MORE efficient way but, - // every granter is a physical person. so they can't - // grant a lot of challenges per second and overload - // the server. That said, it's not that bad. - - const clubScoreRequest = await DB.instance - .select({ - score: sum(challenges.score).as("score") - }) - .from(clubs) - .innerJoin(users, eq(clubs.id, users.clubId)) - .innerJoin(acquired, eq(users.uuid, acquired.userUuid)) - .where(eq(clubs.id, user.clubId)); - - if (clubScoreRequest.length !== 1) return false; - const clubScore = parseInt(clubScoreRequest[0].score ?? "0"); - - await Redis.sortedSet(`leaderboard:clubs`, clubScore, user.clubId); - } - - // Add ETag to the user's leaderboard - await Redis.set(`leaderboard:etag`, crypto.randomUUID()); - - return true; - } -} +import DB from "@/database/config"; +import Redis from "@/database/redis"; +import { acquired } from "@/database/schema/acquired"; +import { challenges } from "@/database/schema/challenges"; +import { users } from "@/database/schema/users"; +import Logger from "@/log/logger"; +import { eq, sum } from "drizzle-orm"; +import UserController from "./users"; +import { clubs } from "@/database/schema/clubs"; +import ClubController from "./clubs"; + +export default abstract class LeaderboardController { + public static async getLeaderboardEtag() { + return await Redis.get("leaderboard:etag"); + } + + public static async getUserLeaderboard() { + const leaderboardUuids = await Redis.sortedAll("leaderboard:users"); + const allUsers = await DB.instance + .select({ + uuid: users.uuid, + username: users.username, + quote: users.quote, + avatarUrl: users.avatarUrl + }) + .from(users); + + const usersMap = new Map(allUsers.map((user) => [user.uuid, user])); + return leaderboardUuids.map((set) => ({ + score: set.score, + user: usersMap.get(set.value) + })); + } + + public static async getClubLeaderboard() { + const leaderboardUuids = await Redis.sortedAll(`leaderboard:clubs`); + const allClubs = await ClubController.getAllClubs(); + + const clubsMap = new Map(allClubs.map((club) => [club.id, club])); + return leaderboardUuids.map((set) => ({ + score: set.score, + club: clubsMap.get(set.value) + })); + } + + public static async grant(userUuid: string, challengeId: number) { + // Check if the user exists + const user = await UserController.getUser(userUuid); + if (!user) return false; + + // Create a new acquired record + try { + await DB.instance.insert(acquired).values({ + userUuid: userUuid, + challengeId: challengeId + }); + } catch (error: unknown) { + Logger.error("leaderboard.ts::grant", error); + return false; + } + + const userScoreRequest = await DB.instance + .select({ + score: sum(challenges.score).as("score") + }) + .from(acquired) + .innerJoin(challenges, eq(acquired.challengeId, challenges.id)) + .where(eq(acquired.userUuid, userUuid)); + + if (userScoreRequest.length !== 1) return false; + const userScore = parseInt(userScoreRequest[0].score ?? "0"); + + // Add/update the user's score in the leaderboard + await Redis.sortedSet("leaderboard:users", userScore, userUuid); + + if (user.clubId) { + // Add/update the club's score in the leaderboard + + //! Not sure if this is the MORE efficient way but, + // every granter is a physical person. so they can't + // grant a lot of challenges per second and overload + // the server. That said, it's not that bad. + + const clubScoreRequest = await DB.instance + .select({ + score: sum(challenges.score).as("score") + }) + .from(clubs) + .innerJoin(users, eq(clubs.id, users.clubId)) + .innerJoin(acquired, eq(users.uuid, acquired.userUuid)) + .where(eq(clubs.id, user.clubId)); + + if (clubScoreRequest.length !== 1) return false; + const clubScore = parseInt(clubScoreRequest[0].score ?? "0"); + + await Redis.sortedSet(`leaderboard:clubs`, clubScore, user.clubId); + } + + // Add ETag to the user's leaderboard + await Redis.set(`leaderboard:etag`, crypto.randomUUID()); + + return true; + } +} diff --git a/docker/compose.coolify.yml b/docker/compose.coolify.yml index 3792f65..19f8b7f 100644 --- a/docker/compose.coolify.yml +++ b/docker/compose.coolify.yml @@ -1,66 +1,66 @@ services: - web: - build: - context: .. - target: production - environment: - - MAIL_SERVER=${MAIL_SERVER} - - MAIL_PORT=${MAIL_PORT:-587} - - MAIL_SECURE=${MAIL_SECURE:-true} - - MAIL_USER=${MAIL_USER:-username} - - MAIL_PASS=${MAIL_PASS} - - MAIL_FROM=${MAIL_FROM:-username } - - MAIL_REDIRECT_URL=${MAIL_REDIRECT_URL:-/auth/confirm#{token}} - - PROFILE_REDIRECT_URL=${PROFILE_REDIRECT_URL:-/profile/{uuid}} + web: + build: + context: .. + target: production + environment: + - MAIL_SERVER=${MAIL_SERVER} + - MAIL_PORT=${MAIL_PORT:-587} + - MAIL_SECURE=${MAIL_SECURE:-true} + - MAIL_USER=${MAIL_USER:-username} + - MAIL_PASS=${MAIL_PASS} + - MAIL_FROM=${MAIL_FROM:-username } + - MAIL_REDIRECT_URL=${MAIL_REDIRECT_URL:-/auth/confirm#{token}} + - PROFILE_REDIRECT_URL=${PROFILE_REDIRECT_URL:-/profile/{uuid}} - - JWT_SECRET=${SERVICE_BASE64_64_JWT_SECRET} - - ADMIN_TOKEN=${SERVICE_BASE64_64_ADMIN_TOKEN} + - JWT_SECRET=${SERVICE_BASE64_64_JWT_SECRET} + - ADMIN_TOKEN=${SERVICE_BASE64_64_ADMIN_TOKEN} - - POSTGRES_DB=${SERVICE_BASE64_POSTGRES_DB} - - POSTGRES_PASSWORD=${SERVICE_BASE64_POSTGRES_PASSWORD} - - POSTGRES_USER=${SERVICE_BASE64_POSTGRES_USER} + - POSTGRES_DB=${SERVICE_BASE64_POSTGRES_DB} + - POSTGRES_PASSWORD=${SERVICE_BASE64_POSTGRES_PASSWORD} + - POSTGRES_USER=${SERVICE_BASE64_POSTGRES_USER} - - MINIO_ROOT_PASSWORD=${SERVICE_BASE64_MINIO_ROOT_PASSWORD} - - MINIO_ROOT_USER=${SERVICE_BASE64_MINIO_ROOT_USER} - - MINIO_DEFAULT_BUCKETS=${SERVICE_BASE64_MINIO_DEFAULT_BUCKETS} + - MINIO_ROOT_PASSWORD=${SERVICE_BASE64_MINIO_ROOT_PASSWORD} + - MINIO_ROOT_USER=${SERVICE_BASE64_MINIO_ROOT_USER} + - MINIO_DEFAULT_BUCKETS=${SERVICE_BASE64_MINIO_DEFAULT_BUCKETS} - - SERVICE_FQDN_ADVENT_3000 + - SERVICE_FQDN_ADVENT_3000 - - TRUST_PROXY=${TRUST_PROXY:-true} - - LOG_FOLDER=${LOG_FOLDER:-/logs} - - POSTGRES_HOST=${POSTGRES_HOST:-postgres} - - REDIS_HOST=${REDIS_HOST:-redis} - - MINIO_HOST=${MINIO_HOST:-minio} + - TRUST_PROXY=${TRUST_PROXY:-true} + - LOG_FOLDER=${LOG_FOLDER:-/logs} + - POSTGRES_HOST=${POSTGRES_HOST:-postgres} + - REDIS_HOST=${REDIS_HOST:-redis} + - MINIO_HOST=${MINIO_HOST:-minio} - volumes: - - applogs:/logs - depends_on: - - postgres - - redis - - minio + volumes: + - applogs:/logs + depends_on: + - postgres + - redis + - minio - postgres: - image: postgres:17-alpine - environment: - - POSTGRES_DB=${SERVICE_BASE64_POSTGRES_DB} - - POSTGRES_PASSWORD=${SERVICE_BASE64_POSTGRES_PASSWORD} - - POSTGRES_USER=${SERVICE_BASE64_POSTGRES_USER} - volumes: - - postgres:/var/lib/postgresql/data + postgres: + image: postgres:17-alpine + environment: + - POSTGRES_DB=${SERVICE_BASE64_POSTGRES_DB} + - POSTGRES_PASSWORD=${SERVICE_BASE64_POSTGRES_PASSWORD} + - POSTGRES_USER=${SERVICE_BASE64_POSTGRES_USER} + volumes: + - postgres:/var/lib/postgresql/data - redis: - image: redis:7.4 + redis: + image: redis:7.4 - minio: - image: "bitnami/minio:latest" - environment: - - MINIO_ROOT_PASSWORD=${SERVICE_BASE64_MINIO_ROOT_PASSWORD} - - MINIO_ROOT_USER=${SERVICE_BASE64_MINIO_ROOT_USER} - - MINIO_DEFAULT_BUCKETS=${SERVICE_BASE64_MINIO_DEFAULT_BUCKETS} - volumes: - - minio:/bitnami/minio/data + minio: + image: "bitnami/minio:latest" + environment: + - MINIO_ROOT_PASSWORD=${SERVICE_BASE64_MINIO_ROOT_PASSWORD} + - MINIO_ROOT_USER=${SERVICE_BASE64_MINIO_ROOT_USER} + - MINIO_DEFAULT_BUCKETS=${SERVICE_BASE64_MINIO_DEFAULT_BUCKETS} + volumes: + - minio:/bitnami/minio/data volumes: - postgres: - minio: - applogs: + postgres: + minio: + applogs: diff --git a/docs/admin-endpoints.md b/docs/admin-endpoints.md new file mode 100644 index 0000000..45785c4 --- /dev/null +++ b/docs/admin-endpoints.md @@ -0,0 +1,140 @@ +# Admin endpoints + +Base url is `https://api-cova-dev.404devinci.fr/admin` + +## Clubs + +### GET /clubs + +No additional data. + +### POST /clubs + +Body: + +```json +{ + "name": "string", + "avatarUrl": "string", + "description": "string (optional)", + "dailyDate": "string (optional)" +} +``` + +Note: If `dailyDate` is not provided, the club will be created with a daily date of today. + +### PUT /clubs/:id + +Params: + +- `id`: number + +Body: + +```json +{ + "name": "string (optional)", + "avatarUrl": "string (optional)", + "description": "string (optional)", + "dailyDate": "string (optional)" +} +``` + +### DELETE /clubs/:id + +Params: + +- `id`: number + +## Challenges + +### GET /challenges + +No additional data. + +### POST /challenges + +Body: + +```json +{ + "clubId": "number", + "score": "number", + "name": "string" +} +``` + +### PUT /challenges/:id + +Params: + +- `id`: number + +Body: + +```json +{ + "score": "number", + "name": "string" +} +``` + +### DELETE /challenges/:id + +Params: + +- `id`: number + +## Granters + +### GET /granters + +Query: + +- `clubId`: number (optional) + +Note: List all granters when no `clubId` is provided, otherwise list granters for the specified `clubId`. + +### POST /granters + +Body: + +```json +{ + "clubId": "number", + "email": "string" +} +``` + +### DELETE /granters/:id + +## Dump + +### GET /dump + +No additional data. + +### POST /dump + +Body: + +```json +{ + "type": "acquired" | "challenges" | "clubs" | "granters" | "users", + "data": "string" +} +``` + +## Notification + +### POST /notification + +Body: + +```json +{ + "title": "string", + "message": "string", + "iconUrl": "string (optional)" +} +``` diff --git a/routes/admin/dump/read.ts b/routes/admin/dump/read.ts new file mode 100644 index 0000000..c6c7cfd --- /dev/null +++ b/routes/admin/dump/read.ts @@ -0,0 +1,27 @@ +import DB from "@/database/config"; +import { acquired } from "@/database/schema/acquired"; +import { challenges } from "@/database/schema/challenges"; +import { clubs } from "@/database/schema/clubs"; +import { granters } from "@/database/schema/granters"; +import { users } from "@/database/schema/users"; +import Status from "@/models/status"; +import { Request, Response, NextFunction } from "express"; + +export default async function Route_AdminDump_Read(req: Request, res: Response, next: NextFunction) { + const dumpAcquired = await DB.instance.select().from(acquired); + const dumpChallenges = await DB.instance.select().from(challenges); + const dumpClubs = await DB.instance.select().from(clubs); + const dumpGranters = await DB.instance.select().from(granters); + const dumpUsers = await DB.instance.select().from(users); + + return Status.send(req, next, { + status: 200, + data: { + acquired: dumpAcquired, + challenges: dumpChallenges, + clubs: dumpClubs, + granters: dumpGranters, + users: dumpUsers + } + }); +} diff --git a/routes/admin/dump/router.ts b/routes/admin/dump/router.ts new file mode 100644 index 0000000..2147ba8 --- /dev/null +++ b/routes/admin/dump/router.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import Route_AdminDump_Read from "./read"; +import Route_AdminDump_Write from "./write"; + +const adminDumpRouter = Router(); + +adminDumpRouter.get("/", Route_AdminDump_Read); +adminDumpRouter.post("/", Route_AdminDump_Write); + +export default adminDumpRouter; diff --git a/routes/admin/dump/write.ts b/routes/admin/dump/write.ts new file mode 100644 index 0000000..19fd8db --- /dev/null +++ b/routes/admin/dump/write.ts @@ -0,0 +1,66 @@ +import DB from "@/database/config"; +import { acquired } from "@/database/schema/acquired"; +import { challenges } from "@/database/schema/challenges"; +import { clubs } from "@/database/schema/clubs"; +import { granters } from "@/database/schema/granters"; +import { users } from "@/database/schema/users"; +import Status from "@/models/status"; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; + +const body = z.object({ + type: z.enum(["acquired", "challenges", "clubs", "granters", "users"]), + data: z.string() +}); + +export default async (req: Request, res: Response, next: NextFunction) => { + const bodyPayload = body.safeParse(req.body); + + if (!bodyPayload.success) { + return Status.send(req, next, { + status: 400, + error: "errors.validation" + }); + } + + try { + switch (bodyPayload.data.type) { + case "acquired": + await DB.instance.insert(acquired).values(JSON.parse(bodyPayload.data.data)); + break; + case "challenges": + await DB.instance.insert(challenges).values(JSON.parse(bodyPayload.data.data)); + break; + case "clubs": + await DB.instance.insert(clubs).values(JSON.parse(bodyPayload.data.data)); + break; + case "granters": + await DB.instance.insert(granters).values(JSON.parse(bodyPayload.data.data)); + break; + case "users": + await DB.instance.insert(users).values(JSON.parse(bodyPayload.data.data)); + break; + default: + return Status.send(req, next, { + status: 400, + error: "errors.validation" + }); + } + } catch (e) { + return Status.send(req, next, { + status: 200, + data: { + success: false, + error: e + } + }); + } + + return Status.send(req, next, { + status: 200, + data: { + success: true, + error: null + } + }); +}; diff --git a/routes/admin/router.ts b/routes/admin/router.ts index 32c5da9..dbb947e 100644 --- a/routes/admin/router.ts +++ b/routes/admin/router.ts @@ -5,6 +5,7 @@ import adminChallengesRouter from "./challenges/router"; import adminClubsRouter from "./clubs/router"; import adminGrantersRouter from "./granters/router"; import Route_Admin_Notification from "./notification"; +import adminDumpRouter from "./dump/router"; const adminRouter = Router(); @@ -13,6 +14,7 @@ adminRouter.use(middlewareAdmin); adminRouter.use("/clubs", adminClubsRouter); adminRouter.use("/challenges", adminChallengesRouter); adminRouter.use("/granters", adminGrantersRouter); +adminRouter.get("/dump", adminDumpRouter); adminRouter.post("/notification", Route_Admin_Notification); diff --git a/routes/daily/read.ts b/routes/daily/read.ts index 3b6b56d..b59277c 100644 --- a/routes/daily/read.ts +++ b/routes/daily/read.ts @@ -8,7 +8,7 @@ import { NextFunction, Request, Response } from "express"; * Sends a response with a status of 200 and the daily club information. */ export default async function Route_Daily_Read(req: Request, res: Response, next: NextFunction) { - const club = await ClubController.getDailyClub(); + const club = await ClubController.getDailyClubs(); if (!club) { return Status.send(req, next, { diff --git a/routes/leaderboard/read.ts b/routes/leaderboard/read.ts index a4f7152..834dfe0 100644 --- a/routes/leaderboard/read.ts +++ b/routes/leaderboard/read.ts @@ -1,31 +1,31 @@ -import LeaderboardController from "@/controllers/leaderboard"; -import Status from "@/models/status"; -import { NextFunction, Request, Response } from "express"; -import { z } from "zod"; - -const query = z.object({ - type: z.enum(["users", "clubs"]).default("users") -}); - -export default async function Route_Leaderboard_Read(req: Request, res: Response, next: NextFunction) { - const queryPayload = query.safeParse(req.query); - - if (!queryPayload.success) { - return Status.send(req, next, { - status: 400, - error: "errors.validation" - }); - } - - if (queryPayload.data.type === "users") { - return Status.send(req, next, { - status: 200, - data: await LeaderboardController.getUserLeaderboard() - }); - } - - return Status.send(req, next, { - status: 200, - data: await LeaderboardController.getClubLeaderboard() - }); -} +import LeaderboardController from "@/controllers/leaderboard"; +import Status from "@/models/status"; +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; + +const query = z.object({ + type: z.enum(["users", "clubs"]).default("users") +}); + +export default async function Route_Leaderboard_Read(req: Request, res: Response, next: NextFunction) { + const queryPayload = query.safeParse(req.query); + + if (!queryPayload.success) { + return Status.send(req, next, { + status: 400, + error: "errors.validation" + }); + } + + if (queryPayload.data.type === "users") { + return Status.send(req, next, { + status: 200, + data: await LeaderboardController.getUserLeaderboard() + }); + } + + return Status.send(req, next, { + status: 200, + data: await LeaderboardController.getClubLeaderboard() + }); +} diff --git a/routes/leaderboard/router.ts b/routes/leaderboard/router.ts index 7ae929e..4e6deb8 100644 --- a/routes/leaderboard/router.ts +++ b/routes/leaderboard/router.ts @@ -1,10 +1,10 @@ -import { Router } from "express"; -import Route_Leaderboard_Read from "./read"; -import Route_Leaderboard_Etag from "./etag"; - -const leaderboardRouter = Router(); - -leaderboardRouter.get("/", Route_Leaderboard_Read); -leaderboardRouter.get("/etag", Route_Leaderboard_Etag); - -export default leaderboardRouter; +import { Router } from "express"; +import Route_Leaderboard_Read from "./read"; +import Route_Leaderboard_Etag from "./etag"; + +const leaderboardRouter = Router(); + +leaderboardRouter.get("/", Route_Leaderboard_Read); +leaderboardRouter.get("/etag", Route_Leaderboard_Etag); + +export default leaderboardRouter; diff --git a/tests/e2e/daily.test.ts b/tests/e2e/daily.test.ts index 906fb21..ba4a6dc 100644 --- a/tests/e2e/daily.test.ts +++ b/tests/e2e/daily.test.ts @@ -68,11 +68,13 @@ describe("Daily challenges", () => { { status: 200, success: true, - data: { - avatarUrl: "https://placehold.co/400", - name: "daily club", - description: "description" - } + data: expect.arrayContaining([ + { + avatarUrl: "https://placehold.co/400", + name: "daily club", + description: "description" + } + ]) } ] }); @@ -90,9 +92,10 @@ describe("Daily challenges", () => { success: true, data: expect.arrayContaining([ { + clubId: testGlobals.clubId, id: expect.any(Number), - name: expect.any(String), - score: expect.any(Number) + name: "daily challenge", + score: 100 } ]) }