diff --git a/GameServer.js b/GameServer.js new file mode 100644 index 0000000..9ebb7a9 --- /dev/null +++ b/GameServer.js @@ -0,0 +1,468 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.registerGameServerEvents = exports.initializeGameServerTasks = void 0; +const lodash_1 = require("lodash"); +const node_schedule_1 = __importDefault(require("node-schedule")); +const parse_duration_1 = __importDefault(require("parse-duration")); +const sequelize_1 = __importDefault(require("sequelize")); +const { Post, Link } = require('./models'); +const { createPost } = require('./Helpers'); +const { Op } = sequelize_1.default; +const POST_TYPE = [ + 'post', + 'comment', + 'bead', + 'poll-answer', + 'card-face', + 'gbg-room-comment', + 'url-block', + 'image-block', + 'audio-block', +]; +function getPost(id) { + return __awaiter(this, void 0, void 0, function* () { + const post = yield Post.findOne({ + raw: true, + nest: true, + where: { + state: 'active', + id + }, + }); + if (!post) { + throw new Error(`Post ${id} not found`); + } + return post; + }); +} +function updatePost(post, data) { + return __awaiter(this, void 0, void 0, function* () { + yield Post.update(data, { where: { id: post.id } }); + return Object.assign(Object.assign({}, post), data); + }); +} +const getFirstMove = (step, variables, players) => { + var _a; + if (!step) { + return undefined; + } + switch (step.type) { + case 'move': + return { step, variables }; + case 'sequence': { + if (step.repeat && ((step.repeat.type === 'rounds' && step.repeat.amount === 0) || (step.repeat.type === 'turns' && players.length === 0))) { + return undefined; + } + const firstStep = getFirstMove(step.steps[0], variables, players); + if (!firstStep) { + return undefined; + } + if (!step.repeat) { + return firstStep; + } + return { + step: firstStep.step, + variables: Object.assign(Object.assign({}, firstStep.variables), { [step.name]: (_a = firstStep.variables[step.name]) !== null && _a !== void 0 ? _a : (step.repeat.type === 'rounds' ? 1 : players[0]) }), + }; + } + default: { + const exhaustivenessCheck = step; + throw exhaustivenessCheck; + } + } +}; +const getTransition = (steps, stepId, variables, players) => { + let current; + let currentVariables = variables; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (current) { + const nextStep = getFirstMove(step, currentVariables, players); + if (nextStep) { + return { current, next: nextStep.step, variables: nextStep.variables }; + } + } + else { + switch (step.type) { + case 'move': + if (step.id === stepId) { + current = step; + } + break; + case 'sequence': { + const result = getTransition(step.steps, stepId, currentVariables, players); + if (!result) { + break; + } + if (result.next) { + return result; + } + if (step.repeat) { + let nextValue; + if (step.repeat.type === 'rounds') { + const round = currentVariables[step.name]; + nextValue = round < +step.repeat.amount ? round + 1 : undefined; + } + else { + const player = currentVariables[step.name]; + const playerIndex = players.findIndex(p => p.id === player.id); + nextValue = players[playerIndex + 1]; + } + if (nextValue) { + const firstStep = getFirstMove(step, Object.assign(Object.assign({}, result.variables), { [step.name]: nextValue }), players); + if (firstStep) { + return { + current: result.current, + next: firstStep.step, + variables: firstStep.variables, + }; + } + } + } + current = result.current; + currentVariables = (0, lodash_1.omit)(result.variables, step.name); + break; + } + default: { + const exhaustivenessCheck = step; + throw exhaustivenessCheck; + } + } + } + } + if (current) { + return { current, variables: currentVariables }; + } + return undefined; +}; +function createChild(data, parent) { + return __awaiter(this, void 0, void 0, function* () { + const { post } = yield createPost(data, [], parent.creatorId); + yield Link.create({ + creatorId: parent.creatorId, + itemAId: parent.id, + itemAType: parent.type, + itemBId: post.id, + itemBType: post.type, + relationship: 'parent', + state: 'active', + totalLikes: 0, + totalComments: 0, + totalRatings: 0 + }); + return post; + }); +} +const variableRegex = /\[([^\]]+)\]/; +function insertVariables(text, variables) { + return text === null || text === void 0 ? void 0 : text.replace(variableRegex, (substring, variableName) => { + if (variableName in variables) { + const value = variables[variableName]; + if (typeof value === 'object') { + return `@${value.name}`; + } + return `${variables[variableName]}`; + } + return substring; + }); +} +function startNewMove(gamePost, step, variables, io) { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + const game = gamePost.game; + const play = game.play; + const now = +new Date(); + const timeout = now + ((_a = (0, parse_duration_1.default)(step.timeout)) !== null && _a !== void 0 ? _a : 0); + const move = Object.assign({ status: 'started', elapsedTime: 0, startedAt: now, timeout, gameId: gamePost.id }, step.submission && ({ + submission: Object.assign(Object.assign({}, step.submission.type === 'audio' ? Object.assign(Object.assign({}, step.submission), { maxDuration: (_b = (0, parse_duration_1.default)(step.submission.maxDuration)) !== null && _b !== void 0 ? _b : 0 }) : Object.assign({}, step.submission)), { player: variables[step.submission.player] }) + })); + const movePost = yield createChild({ + type: 'post', + mediaTypes: '', + title: insertVariables(step.title, variables), + text: insertVariables(step.text, variables), + move + }, gamePost); + scheduleMoveTimeout(movePost, io); + const changedGamePost = yield updatePost(gamePost, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'started', + step, + moveId: movePost.id, + variables: variables, + } }) + }); + return { changedGamePost, changedMoves: [movePost] }; + }); +} +function nextMove(gamePost, io) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + const game = gamePost.game; + const play = game.play; + if (play.status !== 'started' && play.status !== 'paused') { + return; + } + const transition = getTransition(game.steps, play.step.id, play.variables, game.players); + if (transition === null || transition === void 0 ? void 0 : transition.next) { + if (play.status === 'started') { + return yield startNewMove(gamePost, transition.next, transition.variables, io); + } + else { + const changedGamePost = yield updatePost(gamePost, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'paused', + step: transition.next, + variables: transition.variables, + } }) + }); + return { + changedGamePost, + }; + } + } + else { + const changedGamePost = yield updatePost(gamePost, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'ended', + variables: (_a = transition === null || transition === void 0 ? void 0 : transition.variables) !== null && _a !== void 0 ? _a : play.variables + } }) + }); + return { changedGamePost }; + } + }); +} +function moveTimeout(movePost, io) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + const move = movePost.move; + const newMovePost = yield updatePost(movePost, { + move: Object.assign(Object.assign({}, move), { status: 'ended' }) + }); + if (move.gameId) { + const gamePost = yield getPost(move.gameId); + const changes = yield nextMove(gamePost, io); + if (changes) { + emitChanges(io, Object.assign(Object.assign({}, changes), { changedMoves: [newMovePost, ...(_a = changes.changedMoves) !== null && _a !== void 0 ? _a : []] })); + } + } + }); +} +function scheduleMoveTimeout(movePost, io) { + node_schedule_1.default.scheduleJob(movePost.move.timeout, () => __awaiter(this, void 0, void 0, function* () { + const currentMovePost = yield getPost(movePost.id); + if (!(0, lodash_1.isEqual)(currentMovePost.move, movePost.move)) { + console.log('The state of the move has changed, skipping job.'); + return; + } + yield moveTimeout(currentMovePost, io); + })); +} +const EVENTS = { + outgoing: { + update: 'gs:outgoing-update', + start: 'gs:outgoing-start', + stop: 'gs:outgoing-stop', + skip: 'gs:outgoing-skip', + pause: 'gs:outgoing-pause', + submit: 'gs:outgoing-submit' + }, + incoming: { + updated: 'gs:incoming-updated' + } +}; +function initializeGameServerTasks(io) { + return __awaiter(this, void 0, void 0, function* () { + const moves = yield Post.findAll({ + where: { + state: 'active', + 'move': { [Op.not]: null } + } + }); + for (const post of moves) { + const move = post.move; + if (move.status !== 'started') { + continue; + } + if (move.timeout < +new Date()) { + yield moveTimeout(post, io); + } + else { + scheduleMoveTimeout(post, io); + } + } + }); +} +exports.initializeGameServerTasks = initializeGameServerTasks; +function emitChanges(io, { changedGamePost, changedMoves }) { + io.in(changedGamePost.id).emit(EVENTS.incoming.updated, { game: changedGamePost.game, changedChildren: changedMoves }); +} +function registerGameServerEvents(socket, io) { + return __awaiter(this, void 0, void 0, function* () { + socket.on(EVENTS.outgoing.update, (_a) => __awaiter(this, [_a], void 0, function* ({ id, game }) { + const post = yield getPost(id); + const changedGamePost = yield updatePost(post, { + game + }); + emitChanges(io, { changedGamePost }); + })); + socket.on(EVENTS.outgoing.start, (_b) => __awaiter(this, [_b], void 0, function* ({ id }) { + const gamePost = yield getPost(id); + const game = gamePost.game; + const play = game.play; + if (play.status === 'started') { + return; + } + let changes; + if (play.status === 'paused') { + const moveId = play.moveId; + if (!moveId) { + changes = yield startNewMove(gamePost, play.step, play.variables, io); + } + else { + const changedGamePost = yield updatePost(gamePost, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'started', + moveId, + step: play.step, + variables: play.variables, + } }) + }); + const movePost = yield getPost(moveId); + const move = movePost.move; + if (move.status === 'paused') { + const now = +new Date(); + const timeout = now + (0, parse_duration_1.default)(play.step.timeout) - move.elapsedTime; + const newMovePost = yield updatePost(movePost, { + move: Object.assign(Object.assign({}, move), { status: 'started', elapsedTime: move.elapsedTime, startedAt: now, timeout }) + }); + scheduleMoveTimeout(newMovePost, io); + changes = { + changedGamePost, + changedMoves: [newMovePost] + }; + } + else { + changes = { + changedGamePost + }; + } + } + } + else { + const variables = {}; + const step = getFirstMove(game.steps[0], variables, game.players); + if (step) { + changes = yield startNewMove(gamePost, step.step, step.variables, io); + } + else { + const changedGamePost = yield updatePost(gamePost, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'ended', + variables + } }) + }); + changes = { + changedGamePost + }; + } + } + emitChanges(io, changes); + })); + socket.on(EVENTS.outgoing.submit, (_c) => __awaiter(this, [_c], void 0, function* ({ id, moveId }) { + var _d; + const gamePost = yield getPost(id); + const movePost = yield getPost(moveId); + const move = movePost.move; + if (move.status !== 'started') { + return; + } + let completedMovePost = yield updatePost(movePost, { + move: Object.assign(Object.assign({}, move), { status: 'ended' }) + }); + const changes = yield nextMove(gamePost, io); + emitChanges(io, Object.assign(Object.assign({}, changes), { changedMoves: [completedMovePost, ...(_d = changes === null || changes === void 0 ? void 0 : changes.changedMoves) !== null && _d !== void 0 ? _d : []] })); + })); + socket.on(EVENTS.outgoing.skip, (_e) => __awaiter(this, [_e], void 0, function* ({ id }) { + var _f; + const gamePost = yield getPost(id); + const play = gamePost.game.play; + let skippedMovePost; + if ((play.status === 'started' || play.status === 'paused') && play.moveId) { + const movePost = yield getPost(play.moveId); + const move = movePost.move; + if (move.status === 'started' || move.status === 'paused') { + skippedMovePost = yield updatePost(movePost, { + move: Object.assign(Object.assign({}, move), { status: 'skipped' }) + }); + } + } + const changes = yield nextMove(gamePost, io); + emitChanges(io, Object.assign(Object.assign({}, changes), { changedMoves: skippedMovePost && [skippedMovePost, ...(_f = changes === null || changes === void 0 ? void 0 : changes.changedMoves) !== null && _f !== void 0 ? _f : []] })); + })); + socket.on(EVENTS.outgoing.pause, (_g) => __awaiter(this, [_g], void 0, function* ({ id }) { + const post = yield getPost(id); + const game = post.game; + const play = game.play; + if (play.status !== 'started') { + return; + } + const changedGamePost = yield updatePost(post, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'paused', + step: play.step, + variables: play.variables, + moveId: play.moveId, + } }) + }); + const movePost = yield getPost(play.moveId); + const move = movePost.move; + let pausedMovePost; + if (move.status === 'started') { + const now = +new Date(); + pausedMovePost = yield updatePost(movePost, { + move: Object.assign(Object.assign({}, move), { status: 'paused', elapsedTime: move.elapsedTime + now - move.startedAt, remainingTime: move.timeout - now }) + }); + } + emitChanges(io, { changedGamePost, changedMoves: pausedMovePost && [pausedMovePost] }); + })); + socket.on(EVENTS.outgoing.stop, (_h) => __awaiter(this, [_h], void 0, function* ({ id }) { + const post = yield getPost(id); + const game = post.game; + const play = game.play; + if (play.status !== 'started' && play.status !== 'paused') { + return; + } + const changedGamePost = yield updatePost(post, { + game: Object.assign(Object.assign({}, game), { play: { + status: 'stopped', + variables: play.variables, + } }) + }); + let stoppedMovePost; + if (play.moveId) { + const movePost = yield getPost(play.moveId); + const move = movePost.move; + if (move.status === 'started' || move.status === 'paused') { + stoppedMovePost = yield updatePost(movePost, { + move: Object.assign(Object.assign({}, move), { status: 'stopped' }) + }); + } + } + emitChanges(io, { changedGamePost, changedMoves: stoppedMovePost && [stoppedMovePost] }); + })); + }); +} +exports.registerGameServerEvents = registerGameServerEvents; diff --git a/GameServer.ts b/GameServer.ts new file mode 100644 index 0000000..9f0de02 --- /dev/null +++ b/GameServer.ts @@ -0,0 +1,649 @@ +import { isEqual, omit } from 'lodash' +import schedule from 'node-schedule' +import parseDuration from 'parse-duration' +import sequelize from 'sequelize' +import { Server, Socket } from 'socket.io' + +const { Post, Link } = require('./models') +const { createPost } = require('./Helpers') + +const { Op } = sequelize + +const POST_TYPE = [ + 'post', + 'comment', + 'bead', + 'poll-answer', + 'card-face', + 'gbg-room-comment', + 'url-block', + 'image-block', + 'audio-block', +] as const + +type PostType = (typeof POST_TYPE)[number] + +type Post = { + id: number + type: PostType + mediaTypes: string + title: string + text: string + createdAt: string + updatedAt: string + totalComments: number + totalLikes: number + totalRatings: number + totalReposts: number + totalLinks: number + creatorId: number + game?: Game + move?: Move +} + +export type Step = { + id: string + name: string + originalStep?: { gameId: number; stepId: string } +} & ( + | { + type: 'move' + title?: string + text: string + timeout: string + submission?: { player: string } & ({ + type: 'audio' + maxDuration: string + } + | { type: 'text' }) + } + | { + type: 'sequence' + repeat?: { type: 'rounds'; amount: number } | { type: 'turns' } + steps: Step[] + } + ) + +type MoveStep = Extract + +type Game = { + steps: Step[] + play: Play + players: BaseUser[] +} + +export type BaseUser = { + id: number + handle: string + name: string + flagImagePath: string +} + +export type Move = ( + | { status: 'skipped' | 'ended' | 'stopped' | 'timeout' } + | { status: 'paused'; elapsedTime: number; remainingTime: number } + | { + status: 'started' + elapsedTime: number + startedAt: number + timeout: number + } +) & { gameId?: number; submission?: MoveSubmission } +export type MoveSubmission = { player?: BaseUser } & ({ + type: 'audio' + maxDuration: number +} + | { type: 'text' }) +export type MoveSubmissionAudio = Extract + +export type MoveStatus = Move['status'] + +type PlayVariables = Record + +export type Play = { + variables: PlayVariables +} & ( + | { status: 'waiting' | 'stopped' | 'ended' } + | { status: 'paused'; step: MoveStep; moveId?: number } + | { status: 'started' | 'paused'; step: MoveStep; moveId: number } + ) + +async function getPost(id: number): Promise { + const post = await Post.findOne({ + raw: true, + nest: true, + where: { + state: 'active', + id + }, + }) + + if (!post) { + throw new Error(`Post ${id} not found`) + } + + return post; +} + +async function updatePost(post: Post, data: Partial): Promise { + await Post.update(data, { where: { id: post.id } }) + return { + ...post, + ...data, + } +} + +const getFirstMove = ( + step: Step | undefined, + variables: PlayVariables, + players: BaseUser[] +): undefined | { step: MoveStep; variables: PlayVariables } => { + if (!step) { + return undefined + } + switch (step.type) { + case 'move': + return { step, variables } + case 'sequence': { + if (step.repeat && ((step.repeat.type === 'rounds' && step.repeat.amount === 0) || (step.repeat.type === 'turns' && players.length === 0))) { + return undefined + } + + const firstStep = getFirstMove(step.steps[0], variables, players) + if (!firstStep) { + return undefined + } + if (!step.repeat) { + return firstStep + } + + return { + step: firstStep.step, + variables: { + ...firstStep.variables, + [step.name]: firstStep.variables[step.name] ?? (step.repeat.type === 'rounds' ? 1 : players[0]), + }, + } + } + default: { + const exhaustivenessCheck: never = step + throw exhaustivenessCheck + } + } +} + +const getTransition = ( + steps: Step[], + stepId: string, + variables: PlayVariables, + players: BaseUser[] +): undefined | { current: MoveStep; next?: MoveStep; variables: PlayVariables } => { + let current: MoveStep | undefined + let currentVariables = variables + for (let i = 0; i < steps.length; i++) { + const step = steps[i] + + if (current) { + const nextStep = getFirstMove(step, currentVariables, players) + if (nextStep) { + return { current, next: nextStep.step, variables: nextStep.variables } + } + } else { + switch (step.type) { + case 'move': + if (step.id === stepId) { + current = step + } + break + case 'sequence': { + const result = getTransition(step.steps, stepId, currentVariables, players) + if (!result) { + break + } + + if (result.next) { + return result + } + + if (step.repeat) { + let nextValue; + if (step.repeat.type === 'rounds') { + const round = currentVariables[step.name] as number + nextValue = round < +step.repeat.amount ? round + 1 : undefined + } else { + const player = currentVariables[step.name] as BaseUser + const playerIndex = players.findIndex(p => p.id === player.id) + nextValue = players[playerIndex + 1] + } + if (nextValue) { + const firstStep = getFirstMove( + step, + { + ...result.variables, + [step.name]: nextValue, + }, + players + ) + if (firstStep) { + return { + current: result.current, + next: firstStep.step, + variables: firstStep.variables, + } + } + } + } + + current = result.current + currentVariables = omit(result.variables, step.name) + break + } + default: { + const exhaustivenessCheck: never = step + throw exhaustivenessCheck + } + } + } + } + + if (current) { + return { current, variables: currentVariables } + } + + return undefined +} + + +async function createChild(data: any, parent: Post): Promise { + const { post } = await createPost(data, [], parent.creatorId) + await Link.create({ + creatorId: parent.creatorId, + itemAId: parent.id, + itemAType: parent.type, + itemBId: post.id, + itemBType: post.type, + relationship: 'parent', + state: 'active', + totalLikes: 0, + totalComments: 0, + totalRatings: 0 + }) + return post +} + +const variableRegex = /\[([^\]]+)\]/ + +function insertVariables(text: string | undefined, variables: PlayVariables) { + return text?.replace(variableRegex, (substring, variableName) => { + if (variableName in variables) { + const value = variables[variableName] + if (typeof value === 'object') { + return `@${value.name}` + } + return `${variables[variableName]}` + } + return substring + }) +} + +type Changes = { changedGamePost: Post, changedMoves?: Post[] } + +async function startNewMove(gamePost: Post, step: MoveStep, variables: PlayVariables, io: Server): Promise { + const game = gamePost.game! + const play = game.play + const now = +new Date(); + const timeout = now + (parseDuration(step.timeout) ?? 0) + + + const move: Move = { + status: 'started', + elapsedTime: 0, + startedAt: now, + timeout, + gameId: gamePost.id, + ...step.submission && ({ + submission: { + ...step.submission.type === 'audio' ? { + ...step.submission, + maxDuration: parseDuration(step.submission.maxDuration) ?? 0 + } : { + ...step.submission + }, + player: variables[step.submission.player] as BaseUser, + } + }) + } + const movePost = await createChild({ + type: 'post', + mediaTypes: '', + title: insertVariables(step.title, variables), + text: insertVariables(step.text, variables), + move + }, gamePost) + scheduleMoveTimeout(movePost, io) + + const changedGamePost = await updatePost(gamePost, { + game: { + ...game, + play: { + status: 'started', + step, + moveId: movePost.id, + variables: variables, + } + } + }) + + return { changedGamePost, changedMoves: [movePost] } +} + +async function nextMove(gamePost: Post, io: Server): Promise { + const game = gamePost.game!; + const play = game.play!; + if (play.status !== 'started' && play.status !== 'paused') { + return; + } + const transition = getTransition( + game.steps, + play.step.id, + play.variables, + game.players + ) + + if (transition?.next) { + if (play.status === 'started') { + return await startNewMove(gamePost, transition.next, transition.variables, io) + } else { + const changedGamePost = await updatePost(gamePost, { + game: { + ...game, + play: { + status: 'paused', + step: transition.next, + variables: transition.variables, + } + } + }) + return { + changedGamePost, + } + } + } else { + const changedGamePost = await updatePost(gamePost, { + game: { + ...game, + play: { + status: 'ended', + variables: transition?.variables ?? play.variables + } + } + }) + return { changedGamePost } + } +} + + +async function moveTimeout(movePost: Post, io: Server) { + const move = movePost.move! + const newMovePost = await updatePost(movePost, { + move: { + ...move, + status: 'ended' + } + }) + if (move.gameId) { + const gamePost = await getPost(move.gameId); + const changes = await nextMove(gamePost, io) + if (changes) { + emitChanges(io, { ...changes!, changedMoves: [newMovePost, ...changes.changedMoves ?? []] }) + } + } +} + +function scheduleMoveTimeout(movePost: Post, io: Server) { + schedule.scheduleJob((movePost.move! as Extract).timeout, async () => { + const currentMovePost = await getPost(movePost.id) + if (!isEqual(currentMovePost.move, movePost.move)) { + console.log('The state of the move has changed, skipping job.') + return + } + await moveTimeout(currentMovePost, io) + }) +} + +const EVENTS = { + outgoing: { + update: 'gs:outgoing-update', + start: 'gs:outgoing-start', + stop: 'gs:outgoing-stop', + skip: 'gs:outgoing-skip', + pause: 'gs:outgoing-pause', + submit: 'gs:outgoing-submit' + }, + incoming: { + updated: 'gs:incoming-updated' + } +} + +export async function initializeGameServerTasks(io: Server) { + const moves: Post[] = await Post.findAll({ + where: { + state: 'active', + 'move': { [Op.not]: null } + } + }) + + for (const post of moves) { + const move = post.move! + if (move.status !== 'started') { + continue + } + + if (move.timeout < +new Date()) { + await moveTimeout(post, io) + } else { + scheduleMoveTimeout(post, io) + } + } +} + +function emitChanges(io: Server, { changedGamePost, changedMoves }: Changes) { + io.in(changedGamePost.id as any).emit(EVENTS.incoming.updated, { game: changedGamePost.game!, changedChildren: changedMoves }) +} + +export async function registerGameServerEvents(socket: Socket, io: Server) { + socket.on(EVENTS.outgoing.update, async ({ id, game }: { id: number, game: Game }) => { + const post = await getPost(id) + const changedGamePost = await updatePost(post, { + game + }) + + emitChanges(io, { changedGamePost }) + }) + + socket.on(EVENTS.outgoing.start, async ({ id }: { id: number }) => { + const gamePost: Post = await getPost(id) + const game = gamePost.game! + const play = game.play; + + if (play.status === 'started') { + return + } + + let changes: Changes; + if (play.status === 'paused') { + const moveId = play.moveId; + if (!moveId) { + changes = await startNewMove(gamePost, play.step, play.variables, io) + } else { + const changedGamePost = await updatePost(gamePost, { + game: { + ...game, + play: { + status: 'started', + moveId, + step: play.step, + variables: play.variables, + } + } + }) + const movePost = await getPost(moveId); + const move = movePost.move!; + if (move.status === 'paused') { + const now = + new Date(); + const timeout = now + parseDuration(play.step.timeout)! - move.elapsedTime; + const newMovePost = await updatePost(movePost, { + move: { + ...move, + status: 'started', + elapsedTime: move.elapsedTime, + startedAt: now, + timeout, + } + }) + scheduleMoveTimeout(newMovePost, io) + changes = { + changedGamePost, + changedMoves: [newMovePost] + } + } else { + changes = { + changedGamePost + } + } + } + } else { + const variables = {} + const step = getFirstMove(game.steps[0], variables, game.players); + if (step) { + changes = await startNewMove(gamePost, step.step, step.variables, io) + } else { + const changedGamePost = await updatePost(gamePost, { + game: { + ...game, + play: { + status: 'ended', + variables + } + } + }) + changes = { + changedGamePost + } + } + } + emitChanges(io, changes) + }) + + socket.on(EVENTS.outgoing.submit, async ({ id, moveId }) => { + const gamePost = await getPost(id); + const movePost = await getPost(moveId) + const move = movePost.move! + if (move.status !== 'started') { + return + } + let completedMovePost = await updatePost(movePost, { + move: { + ...move, + status: 'ended' + } + }) + const changes = await nextMove(gamePost, io) + emitChanges(io, { ...changes!, changedMoves: [completedMovePost, ...changes?.changedMoves ?? []] }) + }) + + socket.on(EVENTS.outgoing.skip, async ({ id }: { id: number }) => { + const gamePost = await getPost(id); + const play = gamePost.game!.play + let skippedMovePost: Post | undefined; + if ((play.status === 'started' || play.status === 'paused') && play.moveId) { + const movePost = await getPost(play.moveId); + const move = movePost.move!; + if (move.status === 'started' || move.status === 'paused') { + skippedMovePost = await updatePost(movePost, { + move: { + ...move, + status: 'skipped', + } + }) + } + } + const changes = await nextMove(gamePost, io) + emitChanges(io, { ...changes!, changedMoves: skippedMovePost && [skippedMovePost, ...changes?.changedMoves ?? []] }) + }) + + socket.on(EVENTS.outgoing.pause, async ({ id }: { id: number }) => { + const post = await getPost(id) + const game = post.game! + const play = game.play; + + if (play.status !== 'started') { + return; + } + + const changedGamePost = await updatePost(post, { + game: { + ...game, + play: { + status: 'paused', + step: play.step, + variables: play.variables, + moveId: play.moveId, + } + } + }) + + const movePost = await getPost(play.moveId); + const move = movePost.move!; + let pausedMovePost: Post | undefined; + if (move.status === 'started') { + const now = + new Date(); + pausedMovePost = await updatePost(movePost, { + move: { + ...move, + status: 'paused', + elapsedTime: move.elapsedTime + now - move.startedAt, + remainingTime: move.timeout - now + } + }) + } + + emitChanges(io, { changedGamePost, changedMoves: pausedMovePost && [pausedMovePost] }) + }) + + socket.on(EVENTS.outgoing.stop, async ({ id }: { id: number }) => { + const post = await getPost(id) + const game = post.game! + const play = game.play; + + if (play.status !== 'started' && play.status !== 'paused') { + return + } + + const changedGamePost = await updatePost(post, { + game: { + ...game, + play: { + status: 'stopped', + variables: play.variables, + } + } + }) + + let stoppedMovePost: Post | undefined; + if (play.moveId) { + const movePost = await getPost(play.moveId); + const move = movePost.move!; + if (move.status === 'started' || move.status === 'paused') { + stoppedMovePost = await updatePost(movePost, { + move: { + ...move, + status: 'stopped', + } + }) + } + } + + emitChanges(io, { changedGamePost, changedMoves: stoppedMovePost && [stoppedMovePost] }) + }) +} diff --git a/Helpers.js b/Helpers.js index a01f688..38a3b39 100644 --- a/Helpers.js +++ b/Helpers.js @@ -39,10 +39,11 @@ var ffmpeg = require('fluent-ffmpeg') const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path ffmpeg.setFfmpegPath(ffmpegPath) const sgMail = require('@sendgrid/mail') +const { uniq } = require('lodash') sgMail.setApiKey(process.env.SENDGRID_API_KEY) -const imageMBLimit = 10 -const audioMBLimit = 30 +const imageMBLimit = 20 +const audioMBLimit = 100 const defaultPostValues = { state: 'active', watermark: false, @@ -965,6 +966,8 @@ const fullPostAttributes = [ 'totalReposts', 'totalRatings', 'totalLinks', + 'game', + 'move', ] // todo: replace all use cases with const fullPostAttributes above @@ -984,6 +987,8 @@ function findFullPostAttributes(model, accountId) { 'totalReposts', 'totalRatings', 'totalLinks', + 'game', + 'move', // accountLike('post', model, accountId), // accountComment('post', model, accountId), // accountLink('post', model, accountId), @@ -1142,6 +1147,28 @@ function findPostInclude(accountId) { }, }, }, + { + model: Link, + as: 'Originals', + required: false, + where: { relationship: 'remix', state: 'active' }, + include: { + model: Post, + as: 'Parent', + attributes: ['id', 'title', 'game', 'state'], + }, + }, + { + model: Link, + as: 'Remixes', + separate: true, + where: { relationship: 'remix', state: 'active' }, + order: [['index', 'ASC']], + include: { + model: Post, + attributes: ['id', 'title', 'game', 'state'], + }, + }, { model: Reaction, where: { creatorId: accountId, state: 'active' }, @@ -1683,6 +1710,50 @@ function addGBGPlayers(postId, creator, settings) { }) } +async function addRemixes(accountId, game, postId) { + let originals = [] + function findOriginals(steps) { + for (const step of steps) { + if (step.originalStep) { + originals.push(step.originalStep.gameId) + } + if (step.type === 'sequence') { + findOriginals(step.steps) + } + } + } + findOriginals(game.steps) + originals = uniq(originals) + const links = await Link.findAll({ + attributes: ['itemAId'], + where: { + state: 'active', + itemAType: 'post', + itemBType: 'post', + itemAId: originals, + itemBId: postId, + relationship: 'remix', + }, + }) + for (const originalId of originals) { + if (links?.some((link) => link.itemAId === originalId)) { + continue + } + await Link.create({ + creatorId: accountId, + state: 'active', + itemAType: 'post', + itemBType: 'post', + itemAId: originalId, + itemBId: postId, + relationship: 'remix', + totalLikes: 0, + totalComments: 0, + totalRatings: 0, + }) + } +} + // todo: // + check notifyMentions is adding the correct notification type function createPost(data, files, accountId) { @@ -1700,6 +1771,8 @@ function createPost(data, files, accountId) { event, poll, glassBeadGame, + game, + move, card, color, watermark, @@ -1721,10 +1794,16 @@ function createPost(data, files, accountId) { color: color || null, watermark: !!watermark, lastActivity: new Date(), + game, + move, }) + if (game) { + await addRemixes(accountId, game, post.id) + } + // todo: add the correct notification type - const notifyMentions = mentions.length + const notifyMentions = mentions?.length ? await new Promise(async (resolve) => { const users = await User.findAll({ where: { id: mentions, state: 'active' }, @@ -1946,6 +2025,20 @@ function attachComment(comment, parent, accountId) { totalComments: 0, totalRatings: 0, }) + if (parent.relationship !== 'parent') { + await Link.create({ + creatorId: accountId, + itemAId: parent.id, + itemAType: parent.type, + itemBId: comment.id, + itemBType: comment.type, + relationship: parent.relationship, + state: 'active', + totalLikes: 0, + totalComments: 0, + totalRatings: 0, + }) + } const addRootLink = await Link.create({ creatorId: accountId, itemAId: rootPost.id, @@ -2217,6 +2310,7 @@ module.exports = { accountLink, uploadFiles, createPost, + addRemixes, attachComment, scheduleNextBeadDeadline, pushNotification, diff --git a/ScheduledTasks.js b/ScheduledTasks.js index 4671631..04f3135 100644 --- a/ScheduledTasks.js +++ b/ScheduledTasks.js @@ -5,8 +5,10 @@ const { Op } = sequelize const { User, Event, UserEvent, Notification, Post, Weave, GlassBeadGame } = require('./models') const sgMail = require('@sendgrid/mail') sgMail.setApiKey(process.env.SENDGRID_API_KEY) +const io = require('./Socket') +const { initializeGameServerTasks } = require('./GameServer') -function scheduleEventNotification(data) { +async function scheduleEventNotification(data) { const { type, postId, @@ -344,6 +346,8 @@ async function initializeScheduledTasks() { if (nextPlayer) scheduleGBGMoveJobs(id, nextPlayer, moveNumber, nextMoveDeadline) } }) + + await initializeGameServerTasks(io) } module.exports = { diff --git a/Socket.js b/Socket.js index 9f23168..6d7b37d 100644 --- a/Socket.js +++ b/Socket.js @@ -6,6 +6,7 @@ const socketServer = require('http').createServer() const socketIo = require('socket.io') const io = socketIo(socketServer, { cors: { origin: whitelist } }) // socket.io cheatsheet: https://socket.io/docs/v3/emit-cheatsheet/ +const { registerGameServerEvents } = require('./GameServer') const sockets = [] const rooms = [] // space, chat, post, or game + id: `space-58` @@ -200,6 +201,8 @@ io.on('connection', (socket) => { // gameRooms[roomId] = gameRooms[roomId].filter((users) => users.socketId !== socket.id) // } // }) + + registerGameServerEvents(socket, io) }) socketServer.listen(5001) diff --git a/docs/notes.md b/docs/notes.md index 6293140..d35bfd4 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -30,3 +30,7 @@ sudo du -x -h / | sort -h | tail -40 # flush pm2 logs pm2 flush + +# deployment notes + +Change dev script "concurrently -n node,ts \"nodemon Server.js\" \"tsc --watch\"" to "concurrently -n node,ts 'nodemon Server.js' 'tsc --watch'" to work on linux server diff --git a/migration-updates/add-game-to-posts.js b/migration-updates/add-game-to-posts.js new file mode 100644 index 0000000..03ac257 --- /dev/null +++ b/migration-updates/add-game-to-posts.js @@ -0,0 +1,24 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.addColumn( + 'Posts', + 'game', + { + type: Sequelize.DataTypes.JSON, + }, + { transaction: t } + ), + ]) + }) + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.removeColumn('Posts', 'game', { transaction: t }), + ]) + }) + }, +} diff --git a/migration-updates/add-move-to-posts.js b/migration-updates/add-move-to-posts.js new file mode 100644 index 0000000..6c9e860 --- /dev/null +++ b/migration-updates/add-move-to-posts.js @@ -0,0 +1,24 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.addColumn( + 'Posts', + 'move', + { + type: Sequelize.DataTypes.JSON, + }, + { transaction: t } + ), + ]) + }) + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.removeColumn('Posts', 'move', { transaction: t }), + ]) + }) + }, +} diff --git a/migrations/create-post.js b/migrations/create-post.js index 42e91fa..0b960b2 100644 --- a/migrations/create-post.js +++ b/migrations/create-post.js @@ -59,6 +59,12 @@ module.exports = { totalGlassBeadGames: { type: Sequelize.INTEGER, }, + game: { + type: Sequelize.JSON, + }, + move: { + type: Sequelize.JSON, + }, lastActivity: { type: Sequelize.DATE, }, diff --git a/models/Post.js b/models/Post.js index c27f000..0c70f69 100644 --- a/models/Post.js +++ b/models/Post.js @@ -25,6 +25,8 @@ module.exports = (sequelize, DataTypes) => { totalReposts: DataTypes.INTEGER, totalRatings: DataTypes.INTEGER, totalGlassBeadGames: DataTypes.INTEGER, + game: DataTypes.JSON, + move: DataTypes.JSON, lastActivity: DataTypes.DATE, }, {} @@ -48,6 +50,9 @@ module.exports = (sequelize, DataTypes) => { Post.hasMany(models.Link, { as: 'UrlBlocks', foreignKey: 'itemAId' }) Post.hasMany(models.Link, { as: 'ImageBlocks', foreignKey: 'itemAId' }) Post.hasMany(models.Link, { as: 'AudioBlocks', foreignKey: 'itemAId' }) + Post.hasOne(models.Link, { as: 'Originals', foreignKey: 'itemBId' }) + Post.hasMany(models.Link, { as: 'Remixes', foreignKey: 'itemAId' }) + Post.hasMany(models.Link, { as: 'Submissions', foreignKey: 'itemAId' }) Post.hasOne(models.Link, { as: 'MediaLink', foreignKey: 'itemAId' }) // used for post map (todo: rethink...) Post.hasMany(models.Link, { as: 'OutgoingPostLinks', foreignKey: 'itemAId' }) diff --git a/package-lock.json b/package-lock.json index bf54eb1..b7570be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "mysql2": "^3.8.0", "node-schedule": "^2.1.1", "nodemon": "^3.0.3", + "parse-duration": "^1.1.0", "pg": "^8.11.3", "puppeteer": "^20.3.0", "sequelize": "^6.35.2", @@ -36,8 +37,12 @@ "web-push": "^3.6.7" }, "devDependencies": { + "@types/lodash": "^4.17.0", + "@types/node-schedule": "^2.1.6", + "concurrently": "^8.2.2", "eslint": "^8.56.0", - "eslint-config-airbnb-typescript-prettier": "^5.0.0" + "eslint-config-airbnb-typescript-prettier": "^5.0.0", + "typescript": "^5.4.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2203,6 +2208,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -2216,6 +2227,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-schedule": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.6.tgz", + "integrity": "sha512-6AlZSUiNTdaVmH5jXYxX9YgmF1zfOlbjUqw0EllTBmZCnN1R5RR/m/u3No1OiWR05bnQ4jM4/+w4FcGvkAtnKQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -2972,12 +2992,12 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -2985,7 +3005,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -3271,6 +3291,66 @@ "typedarray": "^0.0.6" } }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -3325,9 +3405,9 @@ } }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -3418,6 +3498,22 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3836,13 +3932,14 @@ } }, "node_modules/es5-ext": { - "version": "0.10.62", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", "hasInstallScript": true, "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", "next-tick": "^1.1.0" }, "engines": { @@ -4314,6 +4411,25 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esniff/node_modules/type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -4399,16 +4515,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -4704,9 +4820,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -6323,9 +6439,9 @@ } }, "node_modules/mysql2": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.8.0.tgz", - "integrity": "sha512-rC9J/Wy9TCaoQWhk/p4J0Jd+WCDYghniuawi7pheDqhQOEJyDfiWGiWOR3iPgTFJaOK3GezC7dmCki7cP1HFkQ==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -6717,6 +6833,11 @@ "node": ">=6" } }, + "node_modules/parse-duration": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.0.tgz", + "integrity": "sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7135,9 +7256,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -7310,6 +7431,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", @@ -7689,6 +7819,15 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -7789,6 +7928,12 @@ "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -8032,9 +8177,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -8176,6 +8321,15 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -8326,11 +8480,10 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10583,6 +10736,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, "@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -10596,6 +10755,15 @@ "undici-types": "~5.26.4" } }, + "@types/node-schedule": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.6.tgz", + "integrity": "sha512-6AlZSUiNTdaVmH5jXYxX9YgmF1zfOlbjUqw0EllTBmZCnN1R5RR/m/u3No1OiWR05bnQ4jM4/+w4FcGvkAtnKQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -11133,12 +11301,12 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, "body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "requires": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -11146,7 +11314,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -11364,6 +11532,49 @@ "typedarray": "^0.0.6" } }, + "concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "requires": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + } + } + }, "config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -11406,9 +11617,9 @@ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, "cookie-signature": { "version": "1.0.6", @@ -11481,6 +11692,15 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.21.0" + } + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -11799,12 +12019,13 @@ } }, "es5-ext": { - "version": "0.10.62", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", "requires": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, @@ -12158,6 +12379,24 @@ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true }, + "esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "requires": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "dependencies": { + "type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + } + } + }, "espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -12219,16 +12458,16 @@ "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==" }, "express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -12477,9 +12716,9 @@ } }, "follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" }, "for-each": { "version": "0.3.3", @@ -13659,9 +13898,9 @@ } }, "mysql2": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.8.0.tgz", - "integrity": "sha512-rC9J/Wy9TCaoQWhk/p4J0Jd+WCDYghniuawi7pheDqhQOEJyDfiWGiWOR3iPgTFJaOK3GezC7dmCki7cP1HFkQ==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "requires": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -13948,6 +14187,11 @@ "callsites": "^3.0.0" } }, + "parse-duration": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.0.tgz", + "integrity": "sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==" + }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -14235,9 +14479,9 @@ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, "raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "requires": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -14358,6 +14602,15 @@ "queue-microtask": "^1.2.2" } }, + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, "safe-array-concat": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", @@ -14631,6 +14884,12 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -14704,6 +14963,12 @@ "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" }, + "spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -14893,9 +15158,9 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -15015,6 +15280,12 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true + }, "tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -15131,11 +15402,10 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "devOptional": true, - "peer": true + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "devOptional": true }, "umzug": { "version": "2.3.0", diff --git a/package.json b/package.json index b03271e..b4e3015 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "deps": "docker-compose up --build mysql", "migrate": "sequelize db:migrate", "seed": "sequelize db:seed:all", - "dev": "nodemon Server.js", + "dev": "concurrently -n node,ts \"nodemon Server.js\" \"tsc --watch\"", "start": "node Server.js" }, "keywords": [], @@ -32,6 +32,7 @@ "mysql2": "^3.8.0", "node-schedule": "^2.1.1", "nodemon": "^3.0.3", + "parse-duration": "^1.1.0", "pg": "^8.11.3", "puppeteer": "^20.3.0", "sequelize": "^6.35.2", @@ -41,7 +42,11 @@ "web-push": "^3.6.7" }, "devDependencies": { + "@types/lodash": "^4.17.0", + "@types/node-schedule": "^2.1.6", + "concurrently": "^8.2.2", "eslint": "^8.56.0", - "eslint-config-airbnb-typescript-prettier": "^5.0.0" + "eslint-config-airbnb-typescript-prettier": "^5.0.0", + "typescript": "^5.4.2" } } diff --git a/routes/Post.js b/routes/Post.js index be14a45..653b5cb 100644 --- a/routes/Post.js +++ b/routes/Post.js @@ -34,6 +34,7 @@ const { pushNotification, fullPostAttributes, defaultPostValues, + addRemixes, } = require('../Helpers') const { Space, @@ -363,20 +364,34 @@ router.get('/link-data', authenticateToken, async (req, res) => { res.status(200).json({ source, link, target }) }) -router.get('/target-from-text', authenticateToken, async (req, res) => { +router.get('/search', authenticateToken, async (req, res) => { const accountId = req.user ? req.user.id : null - const { type, sourceId, text, userId } = req.query + const { type, sourceId, search, userId, mediaType, ids } = req.query const where = { type: type.toLowerCase(), state: 'active', - [Op.or]: [{ text: { [Op.like]: `%${text}%` } }, { title: { [Op.like]: `%${text}%` } }], + [Op.or]: [{ text: { [Op.like]: `%${search}%` } }, { title: { [Op.like]: `%${search}%` } }], } if (sourceId) where[Op.not] = { id: sourceId } if (userId) where.creatorId = userId + if (mediaType) { + if (mediaType === 'game') { + where[Op.and] = [ + { mediaTypes: { [Op.like]: `%${mediaType}%` } }, + { [Op.not]: { mediaTypes: { [Op.like]: `%glass-bead-game%` } } }, + ] + } + } else { + where.mediaTypes = { [Op.like]: `%${mediaType}%` } + } + const matchingPosts = await Post.findAll({ where, limit: 10, - include: findPostInclude(accountId), + // TODO: no idea why this fails + include: findPostInclude(accountId).filter( + (include) => !['UrlBlocks', 'ImageBlocks', 'AudioBlocks'].includes(include.as) + ), }) res.status(200).json(matchingPosts) }) @@ -685,7 +700,7 @@ router.get('/post-comments', async (req, res) => { // failed approaches: // + full nested include with no recursive promises (doesn't allow limit beyond first generation) // + get links first instead of using getBlocks function ~1.5s - const { postId, offset, filter } = req.query + const { postId, offset, filter, limit } = req.query const limits = [5, 4, 3, 2, 1] // number of comments to inlcude per generation (length of array determines max depth) const post = await Post.findOne({ where: { id: postId }, @@ -708,7 +723,7 @@ router.get('/post-comments', async (req, res) => { ['id', 'ASC'], ] - async function getChildComments(parent, depth) { + async function getChildComments(parent, depth, limit) { return new Promise(async (resolve) => { const comments = await parent.getBlocks({ attributes: [...fullPostAttributes, 'totalChildComments'], @@ -727,7 +742,7 @@ router.get('/post-comments', async (req, res) => { attributes: ['id', 'handle', 'name', 'flagImagePath'], }, ], - limit: limits[depth], + limit: limit || limits[depth], offset: depth ? 0 : +offset, order, }) @@ -748,7 +763,7 @@ router.get('/post-comments', async (req, res) => { }) } - getChildComments(post, 0) + getChildComments(post, 0, +limit) .then(() => res.status(200).json({ totalChildren: post.totalChildComments, @@ -758,6 +773,91 @@ router.get('/post-comments', async (req, res) => { .catch((error) => res.status(500).json({ message: 'Error', error })) }) +router.get('/post-children', async (req, res) => { + const accountId = req.user ? req.user.id : null + const { postId, limit, offset, childrenIds } = req.query + + const query = { + order: [['createdAt', 'DESC']], + attributes: ['state'], + include: [ + { + model: Post, + attributes: fullPostAttributes, + include: [ + { + model: User, + as: 'Creator', + attributes: ['id', 'handle', 'name', 'flagImagePath', 'coverImagePath'], + }, + { + model: Link, + as: 'Submissions', + separate: true, + where: { relationship: 'submission', state: 'active' }, + order: [['index', 'ASC']], + include: { + model: Post, + attributes: ['id', 'type', 'text'], + include: [ + { + model: User, + as: 'Creator', + attributes: [ + 'id', + 'handle', + 'name', + 'flagImagePath', + 'coverImagePath', + ], + }, + { + model: Link, + as: 'AudioBlocks', + separate: true, + where: { itemBType: 'audio-block' }, + attributes: ['index'], + order: [['index', 'ASC']], + include: { + model: Post, + attributes: ['id', 'text'], + include: { + model: Link, + as: 'MediaLink', + where: { state: 'active', relationship: 'parent' }, + attributes: ['id'], + include: { + model: Audio, + attributes: ['url'], + }, + }, + }, + }, + ], + }, + }, + ], + }, + ], + where: { + state: 'active', + relationship: 'parent', + itemAType: 'post', + itemAId: postId, + }, + } + + if (childrenIds) { + query.where.itemBId = childrenIds.split(',') + } else { + query.offset = +offset + query.limit = +limit + } + + const links = await Link.findAll(query) + res.status(200).json({ children: links.map((link) => link.Post) }) +}) + router.get('/post-indirect-spaces', async (req, res) => { const { postId } = req.query const post = await Post.findOne({ @@ -1350,7 +1450,7 @@ router.post('/create-post', authenticateToken, async (req, res) => { const createNewLink = await Link.create({ state: 'active', creatorId: accountId, - relationship: 'link', + relationship: source.relationship ?? 'link', itemAType: source.type, itemBType: 'post', itemAId: source.id, @@ -1393,7 +1493,7 @@ router.post('/create-comment', authenticateToken, async (req, res) => { }) const parentPost = await Post.findOne({ where: { id: parent.id }, - attributes: ['id', 'type'], + attributes: ['id', 'type', 'game'], include: { model: User, as: 'Creator', @@ -1437,6 +1537,12 @@ router.post('/create-comment', authenticateToken, async (req, res) => {

`, }) + + if (parentPost.game) { + const io = req.app.get('socketio') + io.to(parent.id).emit('gs:incoming-updated', { changedChildren: [post] }) + } + Promise.all([createNotification, sendEmail]) .then(() => resolve()) .catch((error) => resolve(error)) @@ -1572,107 +1678,110 @@ router.post('/create-poll-answer', authenticateToken, async (req, res) => { }) router.post('/create-bead', authenticateToken, async (req, res) => { - const accountId = req.user ? req.user.id : null - if (!accountId) res.status(401).json({ message: 'Unauthorized' }) - else { - const { postData, files } = await uploadFiles(req, res, accountId) - const { post: newBead } = await createPost(postData, files, accountId) - const { parent } = postData.links + try { + const accountId = req.user ? req.user.id : null + if (!accountId) res.status(401).json({ message: 'Unauthorized' }) + else { + const { postData, files } = await uploadFiles(req, res, accountId) + const { post: newBead } = await createPost(postData, files, accountId) + const { parent } = postData.links - const creator = await User.findOne({ - where: { id: accountId }, - attributes: ['name', 'handle'], - }) + const creator = await User.findOne({ + where: { id: accountId }, + attributes: ['name', 'handle'], + }) - const gamePost = await Post.findOne({ - where: { id: parent.id }, - include: [ - { - model: User, - as: 'Creator', - attributes: ['id', 'name', 'handle', 'email', 'emailsDisabled'], - }, - { model: GlassBeadGame }, - { - model: User, - as: 'Players', - attributes: ['id', 'name', 'handle', 'email', 'emailsDisabled'], - through: { where: { type: 'glass-bead-game' }, attributes: ['index'] }, - }, - { - model: Post, - as: 'Beads', - required: false, - through: { where: { state: 'active' }, attributes: ['index'] }, - include: { + const gamePost = await Post.findOne({ + where: { id: parent.id }, + include: [ + { model: User, as: 'Creator', attributes: ['id', 'name', 'handle', 'email', 'emailsDisabled'], }, - }, - ], - }) + { model: GlassBeadGame }, + { + model: User, + as: 'Players', + attributes: ['id', 'name', 'handle', 'email', 'emailsDisabled'], + through: { where: { type: 'glass-bead-game' }, attributes: ['index'] }, + }, + { + model: Post, + as: 'Beads', + required: false, + through: { where: { state: 'active' }, attributes: ['index'] }, + include: { + model: User, + as: 'Creator', + attributes: ['id', 'name', 'handle', 'email', 'emailsDisabled'], + }, + }, + ], + }) - const createLink = await Link.create({ - creatorId: accountId, - itemAId: parent.id, - itemAType: 'post', - itemBId: newBead.id, - itemBType: 'bead', - index: gamePost.GlassBeadGame.totalBeads, - relationship: 'parent', - state: 'active', - totalLikes: 0, - totalComments: 0, - totalRatings: 0, - }) + let newDeadline = 0 - const { synchronous, multiplayer, moveTimeWindow } = gamePost.GlassBeadGame - const notifyPlayers = - !synchronous && multiplayer - ? await new Promise(async (resolve) => { - // find other players to notify - const otherPlayers = [] - if (gamePost.Players.length) { - // if restricted game, use linked Players - otherPlayers.push(...gamePost.Players.filter((p) => p.id !== accountId)) - } else { - // if open game, use linked Bead Creators - gamePost.Beads.forEach((bead) => { - // filter out game creator and existing records - if ( - bead.Creator.id !== accountId && - !otherPlayers.find((p) => p.id === bead.Creator.id) - ) - otherPlayers.push(bead.Creator) - }) - } - // notify players - const sendNotifications = await Promise.all( - otherPlayers.map( - (p) => - new Promise(async (resolve2) => { - const notifyPlayer = await Notification.create({ - type: 'gbg-move-from-other-player', - ownerId: p.id, - postId: parent.id, - userId: accountId, - seen: false, - }) - const emailPlayer = p.emailsDisabled - ? null - : await sgMail.send({ - to: p.email, - from: { - email: 'admin@weco.io', - name: 'we { collective }', - }, - subject: 'New notification', - text: ` + if (gamePost.GlassBeadGame) { + await Link.create({ + creatorId: accountId, + itemAId: parent.id, + itemAType: 'post', + itemBId: newBead.id, + itemBType: 'bead', + index: gamePost.GlassBeadGame.totalBeads, + relationship: 'parent', + state: 'active', + totalLikes: 0, + totalComments: 0, + totalRatings: 0, + }) + + const { synchronous, multiplayer, moveTimeWindow } = gamePost.GlassBeadGame + if (!synchronous && multiplayer) { + await new Promise(async (resolve) => { + // find other players to notify + const otherPlayers = [] + if (gamePost.Players.length) { + // if restricted game, use linked Players + otherPlayers.push(...gamePost.Players.filter((p) => p.id !== accountId)) + } else { + // if open game, use linked Bead Creators + gamePost.Beads.forEach((bead) => { + // filter out game creator and existing records + if ( + bead.Creator.id !== accountId && + !otherPlayers.find((p) => p.id === bead.Creator.id) + ) + otherPlayers.push(bead.Creator) + }) + } + // notify players + const sendNotifications = await Promise.all( + otherPlayers.map( + (p) => + new Promise(async (resolve2) => { + const notifyPlayer = await Notification.create({ + type: 'gbg-move-from-other-player', + ownerId: p.id, + postId: parent.id, + userId: accountId, + seen: false, + }) + const emailPlayer = p.emailsDisabled + ? null + : await sgMail.send({ + to: p.email, + from: { + email: 'admin@weco.io', + name: 'we { collective }', + }, + subject: 'New notification', + text: ` Hi ${p.name}, ${creator.name} just added a new bead. https://${appURL}/p/${parent.id} `, - html: ` + html: `

Hi ${p.name},
@@ -1681,47 +1790,60 @@ router.post('/create-bead', authenticateToken, async (req, res) => { bead.

`, - }) - Promise.all([notifyPlayer, emailPlayer]) - .then(() => resolve2()) - .catch((error) => resolve2(error)) - }) - ) - ) - // schedule next deadline - const scheduleNewDeadline = moveTimeWindow - ? await scheduleNextBeadDeadline( + }) + Promise.all([notifyPlayer, emailPlayer]) + .then(() => resolve2()) + .catch((error) => resolve2(error)) + }) + ) + ) + // schedule next deadline + if (moveTimeWindow) { + newDeadline = await scheduleNextBeadDeadline( parent.id, gamePost.GlassBeadGame, gamePost.Players ) - : null - - Promise.all([sendNotifications, scheduleNewDeadline]) - .then((data) => resolve(data[1])) - .catch((error) => resolve(error)) - }) - : null + } + }) + } - const incrementTotalBeads = await GlassBeadGame.increment('totalBeads', { - where: { postId: parent.id }, - }) + await GlassBeadGame.increment('totalBeads', { + where: { postId: parent.id }, + }) + } else { + await Link.create({ + creatorId: accountId, + itemAId: parent.id, + itemAType: 'post', + itemBId: newBead.id, + itemBType: 'bead', + index: 1, + relationship: 'submission', + state: 'active', + totalLikes: 0, + totalComments: 0, + totalRatings: 0, + }) + } - const updateLastPostActivity = await Post.update( - { lastActivity: new Date() }, - { where: { id: parent.id }, silent: true } - ) + await Post.update( + { lastActivity: new Date() }, + { where: { id: parent.id }, silent: true } + ) - Promise.all([createLink, notifyPlayers, incrementTotalBeads, updateLastPostActivity]) - .then((data) => res.status(200).json({ newBead, newDeadline: data[1] })) - .catch((error) => res.status(500).json({ message: 'Error', error })) + res.status(200).json({ newBead, newDeadline }) + } + } catch (error) { + console.error(error) + res.status(500).json({ message: 'Error', error }) } }) // test router.post('/update-post', authenticateToken, async (req, res) => { const accountId = req.user ? req.user.id : null - const { id, mediaTypes, title, text, searchableText, mentions, urls: newUrls } = req.body + const id = req.body.id const post = await Post.findOne({ where: { id, creatorId: accountId }, attributes: ['id', 'type', 'mediaTypes'], @@ -1733,71 +1855,83 @@ router.post('/update-post', authenticateToken, async (req, res) => { }) if (!post) res.status(401).json({ message: 'Unauthorized' }) else { - const updatePost = await Post.update( - { mediaTypes, title, text, searchableText }, - { where: { id, creatorId: accountId } } - ) - // update urls - const oldUrlBlockLinks = await Link.findAll({ - where: { - itemAId: post.id, - itemAType: post.type, - itemBType: 'url-block', - state: 'active', - }, - attributes: ['id', 'itemBId'], - }) - const oldUrlLinks = await Promise.all( - oldUrlBlockLinks.map( - (oldUrlBlockLink) => - new Promise(async (resolve) => { - const oldUrlLink = await Link.findOne({ - where: { - itemAId: oldUrlBlockLink.itemBId, - itemAType: 'url-block', - itemBType: 'url', - state: 'active', - }, - attributes: [], - include: { model: Url, attributes: ['url'] }, + const toUpdate = {} + for (const key of ['mediaTypes', 'title', 'text', 'searchableText', 'game', 'move']) { + if (key in req.body) { + toUpdate[key] = req.body[key] + } + } + const promises = [] + const updatePost = await Post.update(toUpdate, { where: { id, creatorId: accountId } }) + promises.push(updatePost) + if ('game' in req.body) { + await addRemixes(accountId, req.body.game, id) + } + if ('urls' in req.body) { + const newUrls = req.body.urls + // update urls + const oldUrlBlockLinks = await Link.findAll({ + where: { + itemAId: post.id, + itemAType: post.type, + itemBType: 'url-block', + state: 'active', + }, + attributes: ['id', 'itemBId'], + }) + const oldUrlLinks = await Promise.all( + oldUrlBlockLinks.map( + (oldUrlBlockLink) => + new Promise(async (resolve) => { + const oldUrlLink = await Link.findOne({ + where: { + itemAId: oldUrlBlockLink.itemBId, + itemAType: 'url-block', + itemBType: 'url', + state: 'active', + }, + attributes: [], + include: { model: Url, attributes: ['url'] }, + }) + resolve({ id: oldUrlBlockLink.id, url: oldUrlLink.Url.url }) }) - resolve({ id: oldUrlBlockLink.id, url: oldUrlLink.Url.url }) - }) + ) ) - ) - const removeOldUrls = await Promise.all( - oldUrlLinks.map( - (oldUrlLink) => - new Promise(async (resolve) => { - const match = newUrls.find((newUrl) => newUrl.url === oldUrlLink.url) - if (match) resolve() - else { - Link.update({ state: 'deleted' }, { where: { id: oldUrlLink.id } }) - .then(() => resolve()) - .catch((error) => resolve(error)) - } - }) + const removeOldUrls = await Promise.all( + oldUrlLinks.map( + (oldUrlLink) => + new Promise(async (resolve) => { + const match = newUrls.find((newUrl) => newUrl.url === oldUrlLink.url) + if (match) resolve() + else { + Link.update({ state: 'deleted' }, { where: { id: oldUrlLink.id } }) + .then(() => resolve()) + .catch((error) => resolve(error)) + } + }) + ) ) - ) - const addNewUrls = await Promise.all( - newUrls.map( - (newUrl, index) => - new Promise((resolve) => { - const match = oldUrlLinks.find( - (oldUrlLink) => oldUrlLink.url === newUrl.url - ) - if (match) { - Link.update({ index }, { where: { id: match.id } }) - .then(() => resolve()) - .catch((error) => resolve(error)) - } else { - createUrl(accountId, id, post.type, newUrl, index) - .then(() => resolve()) - .catch((error) => resolve(error)) - } - }) + const addNewUrls = await Promise.all( + newUrls.map( + (newUrl, index) => + new Promise((resolve) => { + const match = oldUrlLinks.find( + (oldUrlLink) => oldUrlLink.url === newUrl.url + ) + if (match) { + Link.update({ index }, { where: { id: match.id } }) + .then(() => resolve()) + .catch((error) => resolve(error)) + } else { + createUrl(accountId, id, post.type, newUrl, index) + .then(() => resolve()) + .catch((error) => resolve(error)) + } + }) + ) ) - ) + promises.push(removeOldUrls, addNewUrls) + } // const oldUrls = await post.getBlocks({ // attributes: ['id'], @@ -1845,46 +1979,47 @@ router.post('/update-post', authenticateToken, async (req, res) => { // ) // notify mentions - const mentionedUsers = await User.findAll({ - where: { handle: mentions, state: 'active' }, - attributes: ['id', 'name', 'email', 'emailsDisabled'], - }) + if ('mentions' in req.body) { + const mentionedUsers = await User.findAll({ + where: { handle: req.body.mentions, state: 'active' }, + attributes: ['id', 'name', 'email', 'emailsDisabled'], + }) - const notifyMentions = await Promise.all( - mentionedUsers.map( - (user) => - new Promise(async (resolve) => { - const alreadySent = await Notification.findOne({ - where: { - ownerId: user.id, - type: `${post.type}-mention`, // post, comment, or bead (todo: poll-answer) - userId: accountId, - postId: id, - }, - }) - if (alreadySent) resolve() - else { - const sendNotification = await Notification.create({ - ownerId: user.id, - type: `${post.type}-mention`, - seen: false, - userId: accountId, - postId: id, + const notifyMentions = await Promise.all( + mentionedUsers.map( + (user) => + new Promise(async (resolve) => { + const alreadySent = await Notification.findOne({ + where: { + ownerId: user.id, + type: `${post.type}-mention`, // post, comment, or bead (todo: poll-answer) + userId: accountId, + postId: id, + }, }) - const sendEmail = user.emailsDisabled - ? null - : await sgMail.send({ - to: user.email, - from: { - email: 'admin@weco.io', - name: 'we { collective }', - }, - subject: 'New notification', - text: ` + if (alreadySent) resolve() + else { + const sendNotification = await Notification.create({ + ownerId: user.id, + type: `${post.type}-mention`, + seen: false, + userId: accountId, + postId: id, + }) + const sendEmail = user.emailsDisabled + ? null + : await sgMail.send({ + to: user.email, + from: { + email: 'admin@weco.io', + name: 'we { collective }', + }, + subject: 'New notification', + text: ` Hi ${user.name}, ${post.Creator.name} just mentioned you in a ${post.type} on weco: http://${appURL}/p/${id} `, - html: ` + html: `

Hi ${user.name},
@@ -1894,18 +2029,23 @@ router.post('/update-post', authenticateToken, async (req, res) => { on weco

`, - }) - Promise.all([sendNotification, sendEmail]) - .then(() => resolve()) - .catch((error) => resolve(error)) - } - }) + }) + Promise.all([sendNotification, sendEmail]) + .then(() => resolve()) + .catch((error) => resolve(error)) + } + }) + ) ) - ) + promises.push(notifyMentions) + } - Promise.all([updatePost, removeOldUrls, addNewUrls, notifyMentions]) + Promise.all(promises) .then(() => res.status(200).json(updatePost)) - .catch((error) => res.status(500).json({ message: 'Error', error })) + .catch((error) => { + console.error(error) + res.status(500).json({ message: 'Error', error }) + }) } }) @@ -3044,6 +3184,25 @@ router.post('/delete-post', authenticateToken, async (req, res) => { { where: { id: postId, creatorId: accountId } } ) + await Link.update( + { state: 'deleted' }, + { + where: { + state: 'active', + [Op.or]: [ + { + itemAType: 'post', + itemAId: postId, + }, + { + itemBType: 'post', + itemBId: postId, + }, + ], + }, + } + ) + const updateSpaceStats = await Promise.all( post.AllPostSpaces.map( (space) => @@ -3093,6 +3252,22 @@ router.post('/delete-comment', authenticateToken, async (req, res) => { { state: 'deleted' }, { where: { id: postId, creatorId: accountId } } ) + await Link.update( + { state: 'deleted' }, + { + where: { + state: 'active', + [Op.or]: [ + { + itemAId: postId, + }, + { + itemBId: postId, + }, + ], + }, + } + ) // get links & root post for tally updates const rootLink = await Link.findOne({ where: { itemBId: postId, itemBType: 'comment', relationship: 'root' }, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c946444 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowJs": false, + "outDir": "./", + "noEmitOnError": false, + }, + "include": [ + "./**/*" + ], + "exclude": [ + "node_modules" + ] +}