import { omit } from 'lodash'; import moment from 'moment'; import { FilterQuery, ObjectID } from 'mongodb'; import { DiscussionNodeNotFoundError, DocumentNotFoundError } from '../error'; import { DiscussionReplyDoc, DiscussionTailReplyDoc, Document } from '../interface'; import * as bus from '../service/bus'; import { NumberKeys } from '../typeutils'; import { buildProjection } from '../utils'; import * as contest from './contest'; import * as document from './document'; import problem from './problem'; import TaskModel from './task'; import * as training from './training'; export interface DiscussionDoc extends Document { } export namespace DiscussionDoc { export type Field = keyof DiscussionDoc; export const fields: Field[] = []; type Getter = () => Partial; const getters: Getter[] = []; export function extend(getter: Getter) { getters.push(getter); fields.push(...Object.keys(getter()) as any); } extend(() => ({ _id: new ObjectID(), domainId: 'system', docType: document.TYPE_DISCUSSION, docId: new ObjectID(), owner: 1, title: '*', content: '', parentId: new ObjectID(), ip: '1.1.1.1', pin: false, highlight: false, updateAt: new Date(), nReply: 0, views: 0, history: [], })); export function create() { const result = {} as DiscussionDoc; for (const getter of getters) { Object.assign(result, getter()); } return result; } } export const PROJECTION_LIST: DiscussionDoc.Field[] = [ '_id', 'domainId', 'docType', 'docId', 'highlight', 'nReply', 'views', 'pin', 'updateAt', 'owner', 'parentId', 'parentType', 'title', ]; export const PROJECTION_PUBLIC: DiscussionDoc.Field[] = [ ...PROJECTION_LIST, 'content', 'history', 'react', 'maintainer', ]; export const typeDisplay = { [document.TYPE_PROBLEM]: 'problem', [document.TYPE_CONTEST]: 'contest', [document.TYPE_DISCUSSION_NODE]: 'node', [document.TYPE_TRAINING]: 'training', [document.TYPE_HOMEWORK]: 'homework', }; export async function add( domainId: string, parentType: number, parentId: ObjectID | number | string, owner: number, title: string, content: string, ip: string | null = null, highlight: boolean, pin: boolean, ): Promise { const payload: Partial = { domainId, content, owner, parentType, parentId, title, ip, nReply: 0, highlight, pin, updateAt: new Date(), views: 0, sort: 100, }; await bus.serial('discussion/before-add', payload); const res = await document.add( payload.domainId!, payload.content!, payload.owner!, document.TYPE_DISCUSSION, null, payload.parentType, payload.parentId, omit(payload, ['domainId', 'content', 'owner', 'parentType', 'parentId']), ); payload.docId = res; await bus.emit('discussion/add', payload); return payload.docId; } export async function get( domainId: string, did: ObjectID, projection: T[] = PROJECTION_PUBLIC as any, ): Promise> { return await document.get(domainId, document.TYPE_DISCUSSION, did, projection); } export function edit( domainId: string, did: ObjectID, title: string, content: string, highlight: boolean, pin: boolean, ): Promise { const payload = { title, content, highlight, pin, }; return document.set(domainId, document.TYPE_DISCUSSION, did, payload); } export function inc( domainId: string, did: ObjectID, key: NumberKeys, value: number, ): Promise { return document.inc(domainId, document.TYPE_DISCUSSION, did, key, value); } export function del(domainId: string, did: ObjectID): Promise { return Promise.all([ document.deleteOne(domainId, document.TYPE_DISCUSSION, did), document.deleteMulti(domainId, document.TYPE_DISCUSSION_REPLY, { parentType: document.TYPE_DISCUSSION, parentId: did, }), document.deleteMultiStatus(domainId, document.TYPE_DISCUSSION, { docId: did }), ]) as any; } export function count(domainId: string, query: FilterQuery) { return document.count(domainId, document.TYPE_DISCUSSION, query); } export function getMulti(domainId: string, query: FilterQuery = {}, projection = PROJECTION_LIST) { return document.getMulti(domainId, document.TYPE_DISCUSSION, query) .sort({ pin: -1, sort: -1 }) .project(buildProjection(projection)); } export async function addReply( domainId: string, did: ObjectID, owner: number, content: string, ip: string, ): Promise { const [drid] = await Promise.all([ document.add( domainId, content, owner, document.TYPE_DISCUSSION_REPLY, null, document.TYPE_DISCUSSION, did, { ip }, ), document.incAndSet(domainId, document.TYPE_DISCUSSION, did, 'nReply', 1, { updateAt: new Date() }), ]); return drid; } export function getReply(domainId: string, drid: ObjectID): Promise { return document.get(domainId, document.TYPE_DISCUSSION_REPLY, drid); } export async function editReply( domainId: string, drid: ObjectID, content: string, ): Promise { return document.set(domainId, document.TYPE_DISCUSSION_REPLY, drid, { content }); } export async function delReply(domainId: string, drid: ObjectID) { const drdoc = await getReply(domainId, drid); if (!drdoc) throw new DocumentNotFoundError(domainId, drid); return await Promise.all([ document.deleteOne(domainId, document.TYPE_DISCUSSION_REPLY, drid), document.inc(domainId, document.TYPE_DISCUSSION, drdoc.parentId, 'nReply', -1), ]); } export function getMultiReply(domainId: string, did: ObjectID) { return document.getMulti( domainId, document.TYPE_DISCUSSION_REPLY, { parentType: document.TYPE_DISCUSSION, parentId: did }, ).sort('_id', -1); } export function getListReply(domainId: string, did: ObjectID): Promise { return getMultiReply(domainId, did).toArray(); } export async function react(domainId: string, docType: keyof document.DocType, did: ObjectID, id: string, uid: number, reverse = false) { let doc; const sdoc = await document.setIfNotStatus(domainId, docType, did, uid, `react.${id}`, reverse ? 0 : 1, reverse ? 0 : 1, {}); if (sdoc) doc = await document.inc(domainId, docType, did, `react.${id}`, reverse ? -1 : 1); else doc = await document.get(domainId, docType, did, ['react']); return [doc, sdoc]; } export async function getReaction(domainId: string, docType: keyof document.DocType, did: ObjectID, uid: number) { const doc = await document.getStatus(domainId, docType, did, uid); return doc?.react || {}; } export async function addTailReply( domainId: string, drid: ObjectID, owner: number, content: string, ip: string, ): Promise<[DiscussionReplyDoc, ObjectID]> { const [drdoc, subId] = await document.push( domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', content, owner, { ip }, ); await document.set( domainId, document.TYPE_DISCUSSION, drdoc.parentId, { updateAt: new Date() }, ); return [drdoc, subId]; } export function getTailReply( domainId: string, drid: ObjectID, drrid: ObjectID, ): Promise<[DiscussionReplyDoc, DiscussionTailReplyDoc] | [null, null]> { return document.getSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid); } export function editTailReply( domainId: string, drid: ObjectID, drrid: ObjectID, content: string, ): Promise { return document.setSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid, { content }); } export async function delTailReply(domainId: string, drid: ObjectID, drrid: ObjectID) { return document.deleteSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid); } export function setStar(domainId: string, did: ObjectID, uid: number, star: boolean) { return document.setStatus(domainId, document.TYPE_DISCUSSION, did, uid, { star }); } export function getStatus(domainId: string, did: ObjectID, uid: number) { return document.getStatus(domainId, document.TYPE_DISCUSSION, did, uid); } export function setStatus(domainId: string, did: ObjectID, uid: number, $set) { return document.setStatus(domainId, document.TYPE_DISCUSSION, did, uid, $set); } export function addNode(domainId: string, _id: string, category: string, args: any = {}) { return document.add( domainId, category, 1, document.TYPE_DISCUSSION_NODE, _id, null, null, args, ); } export function getNode(domainId: string, _id: string) { return document.get(domainId, document.TYPE_DISCUSSION_NODE, _id); } export function flushNodes(domainId: string) { return document.deleteMulti(domainId, document.TYPE_DISCUSSION_NODE); } export async function getVnode(domainId: string, type: number, id: string, uid?: number) { if (type === document.TYPE_PROBLEM) { let pdoc = await problem.get(domainId, Number.isSafeInteger(+id) ? +id : id, problem.PROJECTION_LIST); if (!pdoc) throw new DiscussionNodeNotFoundError(id); if (pdoc.hidden) pdoc = problem.default; return { ...pdoc, type, id: pdoc.docId }; } if ([document.TYPE_CONTEST, document.TYPE_TRAINING, document.TYPE_HOMEWORK].includes(type as any)) { const model = type === document.TYPE_TRAINING ? training : contest; const _id = new ObjectID(id); const tdoc = await model.get(domainId, _id); if (!tdoc) throw new DiscussionNodeNotFoundError(id); if (uid) { const tsdoc = await model.getStatus(domainId, _id, uid); tdoc.attend = tsdoc?.attend || tsdoc?.enroll; } return { ...tdoc, type, id: _id }; } return { title: id, ...await getNode(domainId, id), type, id, }; } export function getNodes(domainId: string) { return document.getMulti(domainId, document.TYPE_DISCUSSION_NODE).toArray(); } export async function getListVnodes(domainId: string, ddocs: any, getHidden = false, assign: string[] = []) { const res = {}; async function task(ddoc: DiscussionDoc) { const vnode = await getVnode(domainId, ddoc.parentType, ddoc.parentId.toString()); if (!res[ddoc.parentType]) res[ddoc.parentType] = {}; if (!getHidden && vnode.hidden) return; if (vnode.assign?.length && Set.intersection(vnode.assign, assign).size) return; res[ddoc.parentType][ddoc.parentId] = vnode; } await Promise.all(ddocs.map((ddoc) => task(ddoc))); return res; } bus.on('problem/delete', async (domainId, docId) => { const dids = await document.getMulti( domainId, document.TYPE_DISCUSSION, { parentType: document.TYPE_PROBLEM, parentId: docId }, ).project({ docId: 1 }).map((ddoc) => ddoc.docId).toArray(); const drids = await document.getMulti( domainId, document.TYPE_DISCUSSION_REPLY, { parentType: document.TYPE_DISCUSSION, parentId: { $in: dids } }, ).project({ docId: 1 }).map((drdoc) => drdoc.docId).toArray(); return await Promise.all([ document.deleteMultiStatus(domainId, document.TYPE_DISCUSSION, { docId: { $in: dids } }), document.deleteMulti(domainId, document.TYPE_DISCUSSION, { docId: { $in: dids } }), document.deleteMulti(domainId, document.TYPE_DISCUSSION_REPLY, { docId: { $in: drids } }), ]); }); const t = Math.exp(-0.15); async function updateSort() { const cursor = document.coll.find({ docType: document.TYPE_DISCUSSION }); // eslint-disable-next-line no-constant-condition while (true) { // eslint-disable-next-line no-await-in-loop const data = await cursor.next(); if (!data) return; // eslint-disable-next-line no-await-in-loop const rCount = await getMultiReply(data.domainId, data.docId).count(); const sort = ((data.sort || 100) + Math.max(rCount - (data.lastRCount || 0), 0) * 10) * t; // eslint-disable-next-line no-await-in-loop await document.coll.updateOne({ _id: data._id }, { $set: { sort, lastRCount: rCount } }); } } TaskModel.Worker.addHandler('discussion.sort', updateSort); bus.once('app/started', async () => { if (!await TaskModel.count({ type: 'schedule', subType: 'discussion.sort' })) { await TaskModel.add({ type: 'schedule', subType: 'discussion.sort', executeAfter: moment().minute(0).second(0).millisecond(0).toDate(), interval: [1, 'hour'], }); } }); global.Hydro.model.discussion = { typeDisplay, DiscussionDoc, PROJECTION_LIST, PROJECTION_PUBLIC, add, get, inc, edit, del, count, getMulti, addReply, getReply, editReply, delReply, getMultiReply, getListReply, addTailReply, getTailReply, editTailReply, delTailReply, react, getReaction, setStar, getStatus, setStatus, addNode, getNode, flushNodes, getNodes, getVnode, getListVnodes, };