Skip to content

Commit

Permalink
🚧 Update users
Browse files Browse the repository at this point in the history
  • Loading branch information
coyotte508 committed Sep 29, 2024
1 parent 9baf94e commit 727aa3f
Show file tree
Hide file tree
Showing 17 changed files with 121 additions and 124 deletions.
4 changes: 2 additions & 2 deletions apps/api/src/config/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
createGameInfoCollection,
createGameNotificationCollection,
} from "@bgs/models";
import { createUserCollection } from "../models";
import { createUserCollection } from "../models/user";

const client = new MongoClient(env.database.bgs.url, { directConnection: true, ignoreUndefined: true });

Expand All @@ -34,7 +34,7 @@ export const collections = {
users: await createUserCollection(db),
};

if (!env.isTest && cluster.isMaster) {
if (!env.isTest && cluster.isPrimary) {
await using lock = await locks.lock("db");

if (lock) {
Expand Down
6 changes: 0 additions & 6 deletions apps/api/src/models/apierror.ts

This file was deleted.

13 changes: 0 additions & 13 deletions apps/api/src/models/index.ts

This file was deleted.

181 changes: 91 additions & 90 deletions apps/api/src/models/user.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { User } from "@bgs/types";
import assert from "assert";
import type { Game, User } from "@bgs/types";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import type { Db, Collection } from "mongodb";
import { ObjectId } from "mongodb";
import { isEmpty, pick } from "lodash";
import { env, sendmail } from "../config";
import type { JsonObject, PickDeep } from "type-fest";
import { collections } from "../config/db";
import { collections, db, locks } from "../config/db";
import { htmlEscape } from "@bgs/utils";
import { GameUtils } from "./game";

export const DEFAULT_KARMA = 75;
export const MAX_KARMA = 100;
Expand Down Expand Up @@ -103,59 +104,35 @@ export namespace UserUtils {
throw new Error("User is already confirmed.");
}
}
}

export async function createUserCollection(db: Db): Promise<Collection<User<ObjectId>>> {
const collection = db.collection<User<ObjectId>>("users");

await collection.createIndex({ "account.username": 1 }, { unique: true });
await collection.createIndex({ "security.slug": 1 }, { unique: true });
await collection.createIndex({ "security.lastIp": 1 });
await collection.createIndex({ "account.email": 1 }, { unique: true, sparse: true });

await collection.createIndex({ "social.facebook": 1 }, { unique: true, sparse: true });
await collection.createIndex({ "social.google": 1 }, { unique: true, sparse: true });
await collection.createIndex({ "social.discord": 1 }, { unique: true, sparse: true });

return collection;
}

export interface UserDocument extends User<ObjectId> {
resetKey(): string;
generateConfirmKey(): void;
confirm(key: string): Promise<void>;
recalculateKarma(since?: Date): Promise<void>;
sendConfirmationEmail(): Promise<void>;
sendMailChangeEmail(newEmail: string): Promise<void>;
sendGameNotificationEmail(): Promise<void>;
updateGameNotification(): Promise<void>;
}
export async function sendMailChangeEmail(user: User<ObjectId>, newEmail: string): Promise<void> {
if (!user.account.email) {
return;
}

schema.method("sendMailChangeEmail", function (this: UserDocument, newEmail: string) {
if (!this.email()) {
return Promise.resolve();
await sendmail({
from: env.noreply,
to: user.account.email,
subject: "Mail change",
html: `
<p>Hello ${user.account.username},</p>
<p>We're here to send you confirmation of your email change to ${htmlEscape(newEmail)}!</p>
<p>If you didn't change your email, please contact us ASAP at ${env.contact}.`,
});
}

return sendmail({
from: env.noreply,
to: this.email(),
subject: "Mail change",
html: `
<p>Hello ${this.account.username},</p>
<p>We're here to send you confirmation of your email change to ${escape(newEmail)}!</p>
<p>If you didn't change your email, please contact us ASAP at ${env.contact}.</p>`,
});
});

schema.method("sendGameNotificationEmail", async function (this: UserDocument) {
const free = await locks.lock("game-notification", this.id);
try {
export async function sendGameNotificationEmail(_user: User<ObjectId>): Promise<void> {
await using lock = await locks.lock(["game-notification", _user._id]);

// Inside the lock, reload the user
const user = await User.findById(this.id);
const user = await collections.users.findOne({ _id: _user._id });

if (!user) {
return;
}

if (!user.settings.mailing.game.activated) {
user.meta.nextGameNotification = undefined;
await user.save();
await collections.users.updateOne({ _id: user._id }, { $set: { "meta.nextGameNotification": undefined } });
return;
}

Expand All @@ -168,27 +145,31 @@ schema.method("sendGameNotificationEmail", async function (this: UserDocument) {
}

/* check if timer already started was present at the time of the last notification for at least one game*/
const count = await Game.count({
currentPlayers: { $elemMatch: { _id: user._id, timerStart: { $lt: user.meta.lastGameNotification } } },
status: "active",
}).limit(1);
const count = await collections.games.countDocuments(
{
currentPlayers: { $elemMatch: { _id: user._id, timerStart: { $lt: user.meta.lastGameNotification } } },
status: "active",
},
{ limit: 1 }
);

if (count > 0) {
return;
}

const activeGames = await Game.findWithPlayersTurn(user.id).select("-data").lean(true);
const activeGames = await GameUtils.findWithPlayersTurn(user._id)
.project<PickDeep<Game<ObjectId>, "_id" | "currentPlayers">>({ _id: 1, currentPlayers: 1 })
.toArray();

if (activeGames.length === 0) {
user.meta.nextGameNotification = undefined;
await user.save();
await collections.users.updateOne({ _id: user._id }, { $set: { "meta.nextGameNotification": null } });
return;
}

/* Check the oldest game where it's your turn */
let lastMove: Date = new Date();
for (const game of activeGames) {
const timerStart = game.currentPlayers.find((pl) => pl._id.equals(this.id))?.timerStart;
const timerStart = game.currentPlayers?.find((pl) => pl._id.equals(user._id))?.timerStart;
if (timerStart && timerStart < lastMove) {
lastMove = timerStart;
}
Expand All @@ -198,25 +179,31 @@ schema.method("sendGameNotificationEmail", async function (this: UserDocument) {
const notificationDate = new Date(lastMove.getTime() + (user.settings.mailing.game.delay || 30 * 60) * 1000);

if (notificationDate > new Date()) {
user.meta.nextGameNotification = notificationDate;
await user.save();
await collections.users.updateOne(
{
_id: user._id,
},
{
$set: { "meta.nextGameNotification": notificationDate },
}
);
return;
}

const gameString = activeGames.length > 1 ? `${activeGames.length} games` : "one game";

// Send email
if (this.email() && this.security.confirmed) {
if (user.account.email && user.security.confirmed) {
sendmail({
from: env.noreply,
to: this.email(),
to: user.account.email,
subject: `Your turn`,
html: `
<p>Hello ${this.account.username}</p>
<p>Hello ${user.account.username}</p>
<p>It's your turn on ${gameString},
click <a href='https://${env.site}/user/${encodeURIComponent(
this.account.username
user.account.username
)}'>here</a> to see your active games.</p>
<p>You can also change your email settings and unsubscribe <a href='http://${
Expand All @@ -228,39 +215,53 @@ schema.method("sendGameNotificationEmail", async function (this: UserDocument) {
user.meta.nextGameNotification = undefined;
user.meta.lastGameNotification = new Date(Date.now());

await user.save();
} catch (err) {
console.error(err);
} finally {
free().catch(console.error);
await collections.users.updateOne(
{ _id: user._id },
{ $set: { "meta.lastGameNotification": user.meta.lastGameNotification, "meta.nextGameNotification": null } }
);
}
});

schema.method("updateGameNotification", async function (this: UserDocument) {
if (!this.settings.mailing.game.activated) {
return;
}
const date = new Date(Date.now() + (this.settings.mailing.game.delay || 30 * 60) * 1000);
if (!this.meta.nextGameNotification || this.meta.nextGameNotification > date) {
this.meta.nextGameNotification = date;
await this.save();
export async function updateGameNotification(user: User<ObjectId>): Promise<void> {
if (!user.settings.mailing.game.activated) {
return;
}
const date = new Date(Date.now() + (user.settings.mailing.game.delay || 30 * 60) * 1000);
if (!user.meta.nextGameNotification || user.meta.nextGameNotification > date) {
user.meta.nextGameNotification = date;
await collections.users.updateOne({ _id: user._id }, { $set: { "meta.nextGameNotification": date } });
}
}
});

schema.method("recalculateKarma", async function (this: UserDocument, since = new Date(0)) {
const games = await Game.find({ "players._id": this._id, lastMove: { $gte: since } }, "status cancelled players", {
lean: true,
}).sort("lastMove");
export async function recalculateKarma(user: User<ObjectId>, since = new Date(0)): Promise<void> {
const games = await collections.games
.find({ "players._id": user._id, lastMove: { $gte: since } }, { projection: { status: 1, cancelled: 1 } })
.toArray();

let karma = DEFAULT_KARMA;
let karma = DEFAULT_KARMA;

for (const game of games) {
if (game.players.find((player) => player._id.equals(this._id)).dropped) {
karma -= 10;
} else if (!game.cancelled && game.status === "ended") {
karma = Math.min(karma + 1, MAX_KARMA);
for (const game of games) {
if (game.players.find((player) => player._id.equals(user._id))?.dropped) {
karma -= 10;
} else if (!game.cancelled && game.status === "ended") {
karma = Math.min(karma + 1, MAX_KARMA);
}
}

await collections.users.updateOne({ _id: user._id }, { $set: { "account.karma": karma } });
}
}

export async function createUserCollection(db: Db): Promise<Collection<User<ObjectId>>> {
const collection = db.collection<User<ObjectId>>("users");

await collection.createIndex({ "account.username": 1 }, { unique: true });
await collection.createIndex({ "security.slug": 1 }, { unique: true });
await collection.createIndex({ "security.lastIp": 1 });
await collection.createIndex({ "account.email": 1 }, { unique: true, sparse: true });

this.account.karma = karma;
});
await collection.createIndex({ "social.facebook": 1 }, { unique: true, sparse: true });
await collection.createIndex({ "social.google": 1 }, { unique: true, sparse: true });
await collection.createIndex({ "social.discord": 1 }, { unique: true, sparse: true });

return collection;
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { env } from "../app/config";
import * as models from "../app/models";
import { env } from "../config";
import * as models from "../models";
import * as data from "./data";

if (process.env.NODE_ENV !== "test") {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const handleError = (err: Error) => {
};

// In production, run a process for each CPU
if (cluster.isMaster && env.isProduction && env.threads > 1) {
if (cluster.isPrimary && env.isProduction && env.threads > 1) {
for (let i = 0; i < env.threads; i++) {
cluster.fork();
}
Expand All @@ -19,6 +19,6 @@ if (cluster.isMaster && env.isProduction && env.threads > 1) {
require("./app/ws");
}

if (cluster.isMaster) {
if (cluster.isPrimary) {
require("./app/services/cron");
}
14 changes: 8 additions & 6 deletions apps/api/src/services/cron.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { collections } from "../config/db";
import env from "../config/env";
import { GameNotification, User } from "../models";
import { GameNotificationUtils } from "../models/gamenotification";
import { UserUtils } from "../models/user";
import { cancelOldOpenGames, processSchedulesGames, processUnreadyGames } from "./game";

/* Check move deadlines every 10 seconds - only on one thread of the server */
if (env.cron) {
setInterval(() => GameNotification.processCurrentMove().catch(console.error), 10000);
setInterval(() => GameNotification.processGameEnded().catch(console.error), 10000);
setInterval(() => GameNotification.processPlayerDrop().catch(console.error), 10000);
setInterval(() => GameNotificationUtils.processCurrentMove().catch(console.error), 10000);
setInterval(() => GameNotificationUtils.processGameEnded().catch(console.error), 10000);
setInterval(() => GameNotificationUtils.processPlayerDrop().catch(console.error), 10000);
setInterval(() => processSchedulesGames().catch(console.error), 1000);
setInterval(() => cancelOldOpenGames().catch(console.error), 5000);
setInterval(() => processUnreadyGames().catch(console.error), 10000);
Expand All @@ -15,10 +17,10 @@ if (env.cron) {
if (env.automatedEmails) {
setInterval(async () => {
try {
const toEmail = await User.find({ "meta.nextGameNotification": { $lte: new Date() } });
const toEmail = await collections.users.find({ "meta.nextGameNotification": { $lte: new Date() } }).toArray();

for (const user of toEmail) {
user.sendGameNotificationEmail().catch((err) => console.error(err));
UserUtils.sendGameNotificationEmail(user).catch((err: any) => console.error(err));
}
} catch (err) {
console.error(err);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/gameinfo.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GameInfo, GamePreferences, User } from "../models";
import { sortBy } from "lodash";
import { seed } from "../../scripts/seed";
import { seed } from "../scripts/seed";
import GameInfoService from "./gameinfo";
import { describe, beforeAll, it, expect } from "vitest";

Expand Down
4 changes: 2 additions & 2 deletions packages/types/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ export interface User<T = string> {
slug: string;
};
meta: {
nextGameNotification?: Date;
lastGameNotification?: Date;
nextGameNotification?: Date | null;
lastGameNotification?: Date | null;
};
authority?: "admin";

Expand Down
12 changes: 12 additions & 0 deletions packages/utils/src/html-escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function htmlEscape(str: string): string {
return str.replace(/[&<]/g, (match) => {
switch (match) {
case "&":
return "&amp;";
case "<":
return "&lt;";
default:
return match;
}
});
}
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./flatten";
export * from "./is-object";
export * from "./typed-include";
export * from "./is-promise";
export * from "./html-escape";

0 comments on commit 727aa3f

Please sign in to comment.