Skip to content

Commit

Permalink
feat/galaki
Browse files Browse the repository at this point in the history
- add game to learn other people names
- leverage ts-fsrs (spaced repetition algorithm)
- database should be initialized and updated with the
  portraitGuessGames/refresh to keep it up to date
- update doc for env variables
  • Loading branch information
JosephMarotte committed Aug 16, 2024
1 parent 5eef1cf commit e732b4c
Show file tree
Hide file tree
Showing 16 changed files with 438 additions and 22 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ on the backend you need to create a `.env` you can copy and modify [.env.example
the part you will need to adapt is the database connection settings

```
MYSQL_HOST
MYSQL_PORT
MYSQL_USER
MYSQL_PASSWORD
MYSQL_DB_NAME
DB_HOST
DB_PORT
DB_USER
DB_PASSWORD
DB_DATABASE
```

## Database
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ DB_PASSWORD=
DB_DATABASE=
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
SESSION_DRIVER=cookie
SESSION_DRIVER=cookie
PORTRAIT_CARD_FETCH_URL=
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import ForbiddenException from "#exceptions/forbidden_exception";
import PortraitGuessable from "#models/portait_guessable";
import PortraitGuess from "#models/portrait_guess";
import User from "#models/user";
import env from "#start/env";
import type { HttpContext } from "@adonisjs/core/http";
import { schema } from "@adonisjs/validator";
import { Rating } from "ts-fsrs";

const GradeValidationSchema = schema.create({
guessId: schema.number(),
grade: schema.enum([Rating.Again, Rating.Good, Rating.Hard, Rating.Easy] as const),
});

export default class PortraitGuessGameController {
async index({ auth }: HttpContext) {
const userId = auth.user!.id;

const portraitsToGuess = await PortraitGuess.query()
.preload("portraitGuessable")
.where("user_id", userId)
.andWhere("due", "<=", new Date())
.orderByRaw("due ASC, RAND()");

return portraitsToGuess.map((p) => p.serialize());
}

async store({ auth, request }: HttpContext) {
const { grade, guessId } = await request.validate({
schema: GradeValidationSchema,
});

const userId = auth.user!.id;
const portraitGuess = await PortraitGuess.findOrFail(guessId);
if (userId !== portraitGuess.userId) {
throw new ForbiddenException();
}
portraitGuess.addGuess(grade);
await portraitGuess.save();
}

async refresh() {
const existingGuessablePortraits = await PortraitGuessable.all();

const refreshPortraitCardsFetch = await fetch(env.get("PORTRAIT_CARD_FETCH_URL"));
const refreshPortraitCards = (await refreshPortraitCardsFetch.json()) as {
UserId: number;
PicturePath: string;
Firstname: string;
Lastname: string;
}[];

const portraitById = Map.groupBy(existingGuessablePortraits, (p) => p.id);

const upToDatePortraitGuessablesPromises = refreshPortraitCards.map(async (p) => {
const existingPortrait = portraitById.get(p.UserId)?.at(0);
const portrait = existingPortrait ?? new PortraitGuessable();
portrait.id = p.UserId;
portrait.pictureUrl = p.PicturePath;
portrait.guess = `${p.Firstname} ${p.Lastname}`;
await portrait.save();
return portrait;
});

const upToDatePortraitGuessables = await Promise.all(upToDatePortraitGuessablesPromises);

const users = await User.query().preload("portraitGuesses");
const newGuessesToCreate = users
.flatMap((user) => {
const userGuessByGuessableId = Map.groupBy(
user.portraitGuesses,
(p) => p.portraitGuessableId,
);
return upToDatePortraitGuessables.flatMap((portraitGuessable) => {
const existingGuess = userGuessByGuessableId.get(portraitGuessable.id)?.at(0);
if (!existingGuess) {
const newGuess = PortraitGuess.createGuess(user.id, portraitGuessable.id);
return newGuess;
}
});
})
.filter((e): e is PortraitGuess => e !== undefined);

await PortraitGuess.createMany(newGuessesToCreate);
}
}
24 changes: 24 additions & 0 deletions apps/backend/app/models/portait_guessable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { BaseModel, column, hasMany } from "@adonisjs/lucid/orm";
import PortraitGuess from "./portrait_guess.js";
import type { HasMany } from "@adonisjs/lucid/types/relations";
import type { DateTime } from "luxon";

export default class PortraitGuessable extends BaseModel {
@column({ isPrimary: true })
declare id: number;

@column()
declare pictureUrl: string;

@column()
declare guess: string;

@hasMany(() => PortraitGuess)
declare portraitGuesses: HasMany<typeof PortraitGuess>;

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime;

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime;
}
71 changes: 71 additions & 0 deletions apps/backend/app/models/portrait_guess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { BaseModel, belongsTo, column } from "@adonisjs/lucid/orm";
import { DateTime } from "luxon";
import { createEmptyCard, FSRS, type Grade, type Card } from "ts-fsrs";
import PortraitGuessable from "./portait_guessable.js";
import type { BelongsTo } from "@adonisjs/lucid/types/relations";

type CardWithNoDueDate = Omit<Card, "due" | "last_review">;

const CARD_COLUMN = {
prepare: (value: Card | null) => (value ? JSON.stringify(value) : null),
consume: (value: string | object | null) => {
if (value === null) {
return null;
}
if (typeof value === "string") {
return JSON.parse(value);
}
return value;
},
};

export default class PortraitGuess extends BaseModel {
@column({ isPrimary: true })
declare id: number;

@column()
declare userId: number;

@column.dateTime()
declare due: DateTime;

@column(CARD_COLUMN)
declare fsrsCard: CardWithNoDueDate;

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime;

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime;

@column()
declare portraitGuessableId: number;

@belongsTo(() => PortraitGuessable)
declare portraitGuessable: BelongsTo<typeof PortraitGuessable>;

get card(): Card {
const card: Card = {
...this.fsrsCard,
due: this.due.toJSDate(),
};
return card;
}

static createGuess(userId: number, portraitGuessableId: number) {
const guess = new PortraitGuess();
guess.userId = userId;
guess.portraitGuessableId = portraitGuessableId;
const { due, ...fsrsCard } = createEmptyCard(new Date());
guess.fsrsCard = fsrsCard;
guess.due = DateTime.fromJSDate(due);
return guess;
}

addGuess(grade: Grade) {
const fsrs = new FSRS({ enable_fuzz: true, enable_short_term: true });
const { card } = fsrs.next(this.card, new Date(), grade);
this.fsrsCard = card;
this.due = DateTime.fromJSDate(card.due);
}
}
4 changes: 4 additions & 0 deletions apps/backend/app/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Notification from "./notification.js";
import RestaurantChoice from "./restaurant_choice.js";
import RestaurantNote from "./restaurant_note.js";
import Theme from "./theme.js";
import PortraitGuess from "./portrait_guess.js";

const AuthFinder = withAuthFinder(() => hash.use("argon"), {
uids: ["username", "email"],
Expand Down Expand Up @@ -81,6 +82,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
@hasMany(() => Notification)
declare notifications: HasMany<typeof Notification>;

@hasMany(() => PortraitGuess)
declare portraitGuesses: HasMany<typeof PortraitGuess>;

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,21 @@ export default class extends BaseSchema {

this.defer(async (db) => {
const oldTokens = (await db.from("api_tokens")) as OldApiToken[];
const newTokensDto = oldTokens.map((oldToken) => ({
tokenable_id: oldToken.user_id,
type: "auth_token",
name: null,
hash: oldToken.token,
abilities: JSON.stringify(["*"]),
created_at: oldToken.created_at,
updated_at: oldToken.created_at,
last_used_at: null,
expires_at: oldToken.expires_at,
}));
if (oldTokens.length > 0) {
const newTokensDto = oldTokens.map((oldToken) => ({
tokenable_id: oldToken.user_id,
type: "auth_token",
name: null,
hash: oldToken.token,
abilities: JSON.stringify(["*"]),
created_at: oldToken.created_at,
updated_at: oldToken.created_at,
last_used_at: null,
expires_at: oldToken.expires_at,
}));

await db.table(this.tableName).multiInsert(newTokensDto);
await db.table(this.tableName).multiInsert(newTokensDto);
}

await db.rawQuery("DROP TABLE IF EXISTS api_tokens");
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { BaseSchema } from "@adonisjs/lucid/schema";

export default class extends BaseSchema {
protected tableName = "portrait_guessables";

async up() {
this.schema.createTable(this.tableName, (table) => {
table.integer("id").unsigned().primary();
table.string("picture_url").notNullable();
table.string("guess").notNullable();
table.timestamp("created_at", { useTz: true });
table.timestamp("updated_at", { useTz: true });
});
}

async down() {
this.schema.dropTable(this.tableName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { BaseSchema } from "@adonisjs/lucid/schema";

export default class extends BaseSchema {
protected tableName = "portrait_guesses";

async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments("id").primary();
table.integer("user_id").unsigned().references("users.id");
table.integer("portrait_guessable_id").unsigned().references("portrait_guessables.id");
table.timestamp("due", { useTz: true }).notNullable();
table.json("fsrs_card").notNullable();
table.timestamp("created_at", { useTz: true });
table.timestamp("updated_at", { useTz: true });
});
}

async down() {
this.schema.dropTable(this.tableName);
}
}
3 changes: 2 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
"luxon": "^3.4.4",
"mysql2": "^3.10.3",
"reflect-metadata": "^0.2.2",
"socket.io": "^4.7.5"
"socket.io": "^4.7.5",
"ts-fsrs": "^4.1.2"
},
"hotHook": {
"boundaries": ["./app/controllers/**/*.ts", "./app/middleware/*.ts"]
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/start/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,9 @@ export default await Env.create(new URL("../", import.meta.url), {
*/
SESSION_DRIVER: Env.schema.enum(["cookie", "memory"] as const),
COOKIE_DOMAIN: Env.schema.string(),

/**
* Variable for fetching portraits
*/
PORTRAIT_CARD_FETCH_URL: Env.schema.string(),
});
3 changes: 3 additions & 0 deletions apps/backend/start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { showRestaurantRewind } from "#controllers/restaurant_rewinds/showRestau
import RestaurantsController from "#controllers/restaurants/RestaurantsController";
import StatisticsController from "#controllers/statistics/StatisticsController";
import TagsController from "#controllers/tags/TagsController";
import PortraitGuessGameController from "#controllers/portrait_guess_game/PortraitGuessGameController";
import { middleware } from "./kernel.js";

router.get("/", async () => {
Expand Down Expand Up @@ -100,6 +101,7 @@ router

router.post("codeNamesGames/addRound/:id", [CodeNamesGamesController, "addRound"]);
router.resource("codeNamesGames", CodeNamesGamesController).apiOnly();
router.resource("portraitGuessGame", PortraitGuessGameController).apiOnly();

router.resource("restaurants/:restaurantId/reviews", RestaurantReviewsController).apiOnly();
router.get("rewind/:id?", showRestaurantRewind);
Expand All @@ -109,6 +111,7 @@ router
})
.use(middleware.auth({ guards: ["web", "api"] }));

router.post("portraitGuessGame/refresh", [PortraitGuessGameController, "refresh"]);
router.post("/caddyLogs", [LogsController, "storeCaddyLogs"]);
router.post("/atopLogs", [LogsController, "storeAtopLogs"]);

Expand Down
5 changes: 2 additions & 3 deletions apps/frontend/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {
AdminPanelSettings,
BugReport,
CalendarMonth,
GitHub,
Lightbulb,
Pause,
RestaurantMenu,
SignalWifiBad,
VideogameAsset,
PsychologyAlt,
} from "@mui/icons-material";
import { Box } from "@mui/material";
import { observer } from "mobx-react-lite";
Expand Down Expand Up @@ -35,6 +33,7 @@ const HomePage = observer(() => {
<IconLink Icon={CalendarMonth} link="/rooms" title="Réservation de salles" />
<IconLink Icon={RestaurantMenu} link="/saveur" title="Restaurants" />
<IconLink Icon={VideogameAsset} link="/games/tournois" title="Platformer" />
<IconLink Icon={PsychologyAlt} link="/games/galaki" title="Galaki" />
<IconLink Icon={Lightbulb} link="/ideas" title="Boîte à idée" />
<IconLink
Icon={GitHub}
Expand Down
Loading

0 comments on commit e732b4c

Please sign in to comment.