You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
726 lines
21 KiB
726 lines
21 KiB
// user.js
|
|
// Copyright (C) 2022 DTP Technologies, LLC
|
|
// License: Apache-2.0
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
|
|
const mongoose = require('mongoose');
|
|
|
|
const User = mongoose.model('User');
|
|
const CoreUser = mongoose.model('CoreUser');
|
|
const UserBlock = mongoose.model('UserBlock');
|
|
|
|
const passport = require('passport');
|
|
const PassportLocal = require('passport-local');
|
|
|
|
const striptags = require('striptags');
|
|
const uuidv4 = require('uuid').v4;
|
|
|
|
const { SiteError, SiteService } = require('../../lib/site-lib');
|
|
|
|
class UserService extends SiteService {
|
|
|
|
constructor (dtp) {
|
|
super(dtp, module.exports);
|
|
|
|
this.USER_SELECT = '_id username username_lc displayName picture';
|
|
|
|
this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names'));
|
|
|
|
this.populateUser = [
|
|
{
|
|
path: 'picture.large',
|
|
},
|
|
{
|
|
path: 'picture.small',
|
|
},
|
|
];
|
|
}
|
|
|
|
async start ( ) {
|
|
await super.start();
|
|
this.log.info(`starting ${module.exports.name} service`);
|
|
|
|
this.registerPassportLocal();
|
|
|
|
if (process.env.DTP_ADMIN === 'enabled') {
|
|
this.registerPassportAdmin();
|
|
}
|
|
}
|
|
|
|
async stop ( ) {
|
|
this.log.info(`stopping ${module.exports.name} service`);
|
|
await super.stop();
|
|
}
|
|
|
|
async create (userDefinition) {
|
|
const NOW = new Date();
|
|
const {
|
|
crypto: cryptoService,
|
|
email: mailService,
|
|
} = this.dtp.services;
|
|
|
|
try {
|
|
userDefinition.email = userDefinition.email.trim().toLowerCase();
|
|
|
|
// strip characters we don't want to allow in username
|
|
userDefinition.username = userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '');
|
|
const username_lc = userDefinition.username.toLowerCase();
|
|
await this.checkUsername(username_lc);
|
|
|
|
// test the email address for validity, blacklisting, etc.
|
|
await mailService.checkEmailAddress(userDefinition.email);
|
|
|
|
// test if we already have a user with this email address
|
|
let user = await User.findOne({ 'email': userDefinition.email.toLowerCase().trim() }).lean();
|
|
if (user) {
|
|
throw new SiteError(400, `An account with email address ${userDefinition.email} already exists.`);
|
|
}
|
|
|
|
// test if we already have a user with this username
|
|
user = await User.findOne({ username_lc }).lean();
|
|
if (user) {
|
|
throw new SiteError(400, `An account with username ${userDefinition.username} already exists.`);
|
|
}
|
|
|
|
const passwordSalt = uuidv4();
|
|
const maskedPassword = cryptoService.maskPassword(passwordSalt, userDefinition.password);
|
|
|
|
user = new User();
|
|
user.created = NOW;
|
|
|
|
user.email = userDefinition.email;
|
|
user.username = userDefinition.username;
|
|
user.username_lc = username_lc;
|
|
user.displayName = striptags(userDefinition.displayName || userDefinition.username);
|
|
|
|
user.passwordSalt = passwordSalt;
|
|
user.password = maskedPassword;
|
|
|
|
user.flags = {
|
|
isAdmin: userDefinition.isAdmin || false,
|
|
isModerator: userDefinition.isModerator || false,
|
|
isEmailVerified: userDefinition.isEmailVerified || false,
|
|
};
|
|
|
|
user.permissions = {
|
|
canLogin: userDefinition.canLogin || true,
|
|
canChat: userDefinition.canChat || true,
|
|
canComment: userDefinition.canComment || true,
|
|
canReport: userDefinition.canReport || true,
|
|
};
|
|
|
|
user.optIn = {
|
|
system: userDefinition.optInSystem || true,
|
|
marketing: userDefinition.optInMarketing || false,
|
|
};
|
|
|
|
this.log.info('creating new user account', { email: userDefinition.email });
|
|
await user.save();
|
|
|
|
await this.sendWelcomeEmail(user);
|
|
|
|
return user.toObject();
|
|
} catch (error) {
|
|
this.log.error('failed to create user', { error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async sendWelcomeEmail (user) {
|
|
const { email: emailService } = this.dtp.services;
|
|
|
|
/*
|
|
* Remove all pending EmailVerify tokens for the User.
|
|
*/
|
|
await emailService.removeVerificationTokensForUser(user);
|
|
|
|
/*
|
|
* Create the new/only EmailVerify token for the user. This will be the only
|
|
* token accepted. Previous emails sent (if they were received) are invalid
|
|
* after this.
|
|
*/
|
|
const verifyToken = await emailService.createVerificationToken(user);
|
|
|
|
/*
|
|
* Send the welcome email using the new EmailVerify token so it can
|
|
* construct a new, valid link to use for verifying the email address.
|
|
*/
|
|
const templateModel = {
|
|
site: this.dtp.config.site,
|
|
recipient: user,
|
|
emailVerifyToken: verifyToken.token,
|
|
};
|
|
const message = {
|
|
from: process.env.DTP_EMAIL_SMTP_FROM,
|
|
to: user.email,
|
|
subject: `Welcome to ${this.dtp.config.site.name}!`,
|
|
html: await emailService.renderTemplate('welcome', 'html', templateModel),
|
|
text: await emailService.renderTemplate('welcome', 'text', templateModel),
|
|
};
|
|
await emailService.send(message);
|
|
}
|
|
|
|
async setEmailVerification (user, isVerified) {
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: { 'flags.isEmailVerified': isVerified },
|
|
},
|
|
);
|
|
}
|
|
|
|
async emailOptOut (userId, category) {
|
|
userId = mongoose.Types.ObjectId(userId);
|
|
const user = await this.getUserAccount(userId);
|
|
if (!user) {
|
|
throw new SiteError(406, 'Invalid opt-out token');
|
|
}
|
|
|
|
const updateOp = { $set: { } };
|
|
switch (category) {
|
|
case 'marketing':
|
|
updateOp.$set['optIn.marketing'] = false;
|
|
break;
|
|
case 'system':
|
|
updateOp.$set['optIn.system'] = false;
|
|
break;
|
|
default:
|
|
throw new SiteError(406, 'Invalid opt-out category');
|
|
}
|
|
|
|
await User.updateOne({ _id: userId }, updateOp);
|
|
}
|
|
|
|
async update (user, userDefinition) {
|
|
if (!user.flags.canLogin) {
|
|
throw SiteError(403, 'Invalid user account operation');
|
|
}
|
|
|
|
// strip characters we don't want to allow in username
|
|
userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
|
|
const username_lc = userDefinition.username.toLowerCase();
|
|
|
|
userDefinition.displayName = striptags(userDefinition.displayName.trim());
|
|
|
|
this.log.info('updating user', { userDefinition });
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: {
|
|
username: userDefinition.username,
|
|
username_lc,
|
|
displayName: userDefinition.displayName,
|
|
'optIn.system': userDefinition['optIn.system'] === 'on',
|
|
'optIn.marketing': userDefinition['optIn.marketing'] === 'on',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async updateForAdmin (user, userDefinition) {
|
|
// strip characters we don't want to allow in username
|
|
userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
|
|
const username_lc = userDefinition.username.toLowerCase();
|
|
|
|
userDefinition.displayName = striptags(userDefinition.displayName.trim());
|
|
|
|
if (userDefinition.badges) {
|
|
userDefinition.badges = userDefinition.badges.split(',').map((badge) => striptags(badge.trim()));
|
|
} else {
|
|
userDefinition.badges = [ ];
|
|
}
|
|
|
|
this.log.info('updating user for admin', { userDefinition });
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: {
|
|
username: userDefinition.username,
|
|
username_lc,
|
|
displayName: userDefinition.displayName,
|
|
bio: striptags(userDefinition.bio.trim()),
|
|
badges: userDefinition.badges,
|
|
|
|
'flags.isAdmin': userDefinition.isAdmin === 'on',
|
|
'flags.isModerator': userDefinition.isModerator === 'on',
|
|
'flags.isEmailVerified': userDefinition.isEmailVerified === 'on',
|
|
|
|
'permissions.canLogin': userDefinition.canLogin === 'on',
|
|
'permissions.canChat': userDefinition.canChat === 'on',
|
|
'permissions.canComment': userDefinition.canComment === 'on',
|
|
'permissions.canReport': userDefinition.canReport === 'on',
|
|
|
|
'optIn.system': userDefinition.optInSystem === 'on',
|
|
'optIn.marketing': userDefinition.optInMarketing === 'on',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async updateSettings (user, userDefinition) {
|
|
const { crypto: cryptoService } = this.dtp.services;
|
|
|
|
const updateOp = { $set: { }, $unset: { } };
|
|
|
|
// strip characters we don't want to allow in username
|
|
updateOp.$set.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
|
|
if (!updateOp.$set.username || (updateOp.$set.username.length === 0)) {
|
|
throw new SiteError(400, 'Must include a username');
|
|
}
|
|
updateOp.$set.username_lc = updateOp.$set.username.toLowerCase();
|
|
|
|
if (userDefinition.displayName && (userDefinition.displayName.length > 0)) {
|
|
updateOp.$set.displayName = striptags(userDefinition.displayName.trim());
|
|
} else {
|
|
updateOp.$unset.displayName = 1;
|
|
}
|
|
|
|
if (userDefinition.bio && (userDefinition.bio.length > 0)) {
|
|
updateOp.$set.bio = striptags(userDefinition.bio.trim());
|
|
} else {
|
|
updateOp.$unset.bio = 1;
|
|
}
|
|
|
|
if (userDefinition.password && userDefinition.password.length > 0) {
|
|
updateOp.$set.passwordSalt = uuidv4();
|
|
updateOp.$set.password = cryptoService.maskPassword(updateOp.$set.passwordSalt, userDefinition.password);
|
|
}
|
|
|
|
updateOp.$set.theme = userDefinition.theme || 'dtp-light';
|
|
|
|
this.log.info('updating user settings', { userId: user._id });
|
|
await User.updateOne({ _id: user._id }, updateOp);
|
|
}
|
|
|
|
async authenticate (account, options) {
|
|
const { crypto } = this.dtp.services;
|
|
|
|
options = Object.assign({
|
|
adminRequired: false,
|
|
}, options);
|
|
|
|
const accountEmail = account.username.trim().toLowerCase();
|
|
const accountUsername = await this.filterUsername(accountEmail);
|
|
|
|
this.log.debug('locating user record', { accountEmail, accountUsername });
|
|
const user = await User
|
|
.findOne({
|
|
$or: [
|
|
{ email: accountEmail },
|
|
{ username_lc: accountUsername },
|
|
]
|
|
})
|
|
.select('+passwordSalt +password +flags +optIn +permissions')
|
|
.lean();
|
|
if (!user) {
|
|
throw new SiteError(404, 'Member credentials are invalid');
|
|
}
|
|
|
|
const maskedPassword = crypto.maskPassword(
|
|
user.passwordSalt,
|
|
account.password,
|
|
);
|
|
if (maskedPassword !== user.password) {
|
|
throw new SiteError(403, 'Member credentials are invalid');
|
|
}
|
|
|
|
// remove these critical fields from the user object
|
|
delete user.passwordSalt;
|
|
delete user.password;
|
|
|
|
if (options.adminRequired && !user.flags.isAdmin) {
|
|
throw new SiteError(403, 'Admin privileges required');
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
registerPassportLocal ( ) {
|
|
const options = {
|
|
usernameField: 'username',
|
|
passwordField: 'password',
|
|
session: true,
|
|
};
|
|
passport.use('dtp-local', new PassportLocal(options, this.handleLocalLogin.bind(this)));
|
|
}
|
|
|
|
async handleLocalLogin (username, password, done) {
|
|
const now = new Date();
|
|
this.log.info('handleLocalLogin', { username });
|
|
try {
|
|
const user = await this.authenticate({ username, password }, { adminRequired: false });
|
|
await this.startUserSession(user, now);
|
|
done(null, this.filterUserObject(user));
|
|
} catch (error) {
|
|
this.log.error('failed to process local user login', { error });
|
|
done(error);
|
|
}
|
|
}
|
|
|
|
registerPassportAdmin ( ) {
|
|
const options = {
|
|
usernameField: 'username',
|
|
passwordField: 'password',
|
|
session: true,
|
|
};
|
|
this.log.info('registering PassportJS admin strategy', { options });
|
|
passport.use('dtp-admin', new PassportLocal(options, this.handleAdminLogin.bind(this)));
|
|
}
|
|
|
|
async handleAdminLogin (email, password, done) {
|
|
const now = new Date();
|
|
try {
|
|
const user = await this.authenticate({ email, password }, { adminRequired: true });
|
|
await this.startUserSession(user, now);
|
|
done(null, this.filterUserObject(user));
|
|
} catch (error) {
|
|
this.log.error('failed to process admin user login', { error });
|
|
done(error);
|
|
}
|
|
}
|
|
|
|
async startUserSession (user, now) {
|
|
user.type = 'User';
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: { 'stats.lastLogin': now },
|
|
$inc: { 'stats.loginCount': 1 },
|
|
},
|
|
);
|
|
}
|
|
|
|
filterUserObject (user) {
|
|
const filteredUser = {
|
|
_id: user._id,
|
|
created: user.created,
|
|
displayName: user.displayName,
|
|
username: user.username,
|
|
username_lc: user.username_lc,
|
|
bio: user.bio,
|
|
flags: user.flags,
|
|
permissions: user.permissions,
|
|
picture: user.picture,
|
|
};
|
|
if (filteredUser.flags && filteredUser.flags._id) {
|
|
delete filteredUser.flags._id;
|
|
}
|
|
if (filteredUser.permissions && filteredUser.permissions._id) {
|
|
delete filteredUser.permissions._id;
|
|
}
|
|
return filteredUser;
|
|
}
|
|
|
|
async getUserAccount (userId) {
|
|
const user = await User
|
|
.findById(userId)
|
|
.select('+email +flags +permissions +optIn +picture')
|
|
.populate(this.populateUser)
|
|
.lean();
|
|
if (!user) {
|
|
throw new SiteError(404, 'Member account not found');
|
|
}
|
|
user.type = 'User';
|
|
return user;
|
|
}
|
|
|
|
async getUserAccounts (pagination, username) {
|
|
let search = { };
|
|
if (username) {
|
|
search.username_lc = { $regex: `^${username.toLowerCase().trim()}` };
|
|
}
|
|
const users = await User
|
|
.find(search)
|
|
.sort({ username_lc: 1 })
|
|
.select('+email +flags +permissions +optIn')
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.lean()
|
|
;
|
|
|
|
return users.map((user) => { user.type = 'User'; return user; });
|
|
}
|
|
|
|
async getUserProfile (userId) {
|
|
let user;
|
|
try {
|
|
userId = mongoose.Types.ObjectId(userId); // will throw if invalid format
|
|
user = User.findById(userId);
|
|
} catch (error) {
|
|
user = User.findOne({ username: userId });
|
|
}
|
|
user = await user
|
|
.select('+email +flags +settings')
|
|
.populate(this.populateUser)
|
|
.lean();
|
|
return user;
|
|
}
|
|
|
|
async getPublicProfile (username) {
|
|
if (!username || (typeof username !== 'string')) {
|
|
throw new SiteError(406, 'Invalid username');
|
|
}
|
|
|
|
username = username.trim().toLowerCase();
|
|
if (username.length === 0) {
|
|
throw new SiteError(406, 'Invalid username');
|
|
}
|
|
|
|
/**
|
|
* Try to resolve the user as a CoreUser
|
|
*/
|
|
let user = await CoreUser
|
|
.findOne({ username_lc: username })
|
|
.select('_id created username username_lc displayName bio picture header core')
|
|
.populate(this.populateUser)
|
|
.lean();
|
|
if (user) {
|
|
user.type = 'CoreUser';
|
|
} else {
|
|
/*
|
|
* Try to resolve the user as a local User
|
|
*/
|
|
user = await User
|
|
.findOne({ username_lc: username })
|
|
.select('_id created username username_lc displayName bio picture header')
|
|
.populate(this.populateUser)
|
|
.lean();
|
|
if (user) {
|
|
user.type = 'User';
|
|
}
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
async getRecent (maxCount = 3) {
|
|
const users = User
|
|
.find()
|
|
.select(UserService.USER_SELECT)
|
|
.sort({ created: -1 })
|
|
.limit(maxCount)
|
|
.lean();
|
|
return users;
|
|
}
|
|
|
|
async setUserSettings (user, settings) {
|
|
const {
|
|
crypto: cryptoService,
|
|
mail: mailService,
|
|
phone: phoneService,
|
|
} = this.dtp.platform.services;
|
|
|
|
const update = { $set: { } };
|
|
const actions = [ ];
|
|
|
|
if (settings.name && (settings.name !== user.name)) {
|
|
update.name = striptags(settings.name.trim());
|
|
update.name_lc = update.name.toLowerCase();
|
|
actions.push('Display name updated');
|
|
}
|
|
|
|
if (settings.username && (settings.username !== user.username)) {
|
|
update.username = this.filterUsername(settings.username);
|
|
await this.checkUsername(update.username);
|
|
}
|
|
|
|
if (settings.email && (settings.email !== user.email)) {
|
|
settings.email = settings.email.toLowerCase().trim();
|
|
await mailService.checkEmailAddress(settings.email);
|
|
update.$set['flags.isEmailVerified'] = false;
|
|
update.$set.email = settings.email;
|
|
actions.push('Email address updated and verification email sent. Please check your inbox and follow the instructions included to complete the change of your email address.');
|
|
}
|
|
|
|
/*
|
|
* User is changing the phone number stored on the account.
|
|
* "There's a lot to unpack here"
|
|
*/
|
|
if (settings.phone) {
|
|
// update the phone number (there's a lot going on here)
|
|
try {
|
|
update.$set.phone = await phoneService.processPhoneNumberInput(settings.phone);
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
|
|
// un-verify the account's phone number
|
|
update.$set['flags.isPhoneVerified'] = false;
|
|
|
|
actions.push('Phone number updated and verification message sent. Please follow the instructions in the text message to complete the change of your mobile phone number.');
|
|
}
|
|
|
|
if (settings.password) {
|
|
if (settings.password !== settings.passwordv) {
|
|
throw new SiteError(400, 'Password and password verification do not match.');
|
|
}
|
|
update.$set.passwordSalt = uuidv4();
|
|
update.$set.password = cryptoService.maskPassword(update.$set.passwordSalt, settings.password);
|
|
actions.push('Password changed successfully.');
|
|
}
|
|
|
|
if (settings.theme) {
|
|
update.$set['settings.theme'] = striptags(settings.theme.trim());
|
|
}
|
|
if (settings.language) {
|
|
update.$set['settings.language'] = mongoose.Types.ObjectId(settings.language);
|
|
actions.push('Interface language changed.');
|
|
}
|
|
|
|
await User.updateOne({ _id: user._id }, update);
|
|
|
|
return actions;
|
|
}
|
|
|
|
async filterUsername (username) {
|
|
return striptags(username.trim().toLowerCase()).replace(/\W/g, '');
|
|
}
|
|
|
|
async checkUsername (username) {
|
|
if (!username || (typeof username !== 'string') || (username.length === 0)) {
|
|
throw new SiteError(406, 'Invalid username');
|
|
}
|
|
if (this.reservedNames.includes(username.trim().toLowerCase())) {
|
|
throw new SiteError(403, 'That username is reserved for system use');
|
|
}
|
|
|
|
const user = await User.findOne({ username: username}).select('username').lean();
|
|
if (user) {
|
|
this.log.alert('username is already registered', { username });
|
|
throw new SiteError(403, 'Username is already registered');
|
|
}
|
|
}
|
|
|
|
async recordProfileView (user, req) {
|
|
const { resource: resourceService } = this.dtp.services;
|
|
await resourceService.recordView(req, 'User', user._id);
|
|
}
|
|
|
|
async getTotalCount ( ) {
|
|
return await User.estimatedDocumentCount();
|
|
}
|
|
|
|
async updatePhoto (user, file) {
|
|
const { image: imageService } = this.dtp.services;
|
|
const images = [
|
|
{
|
|
width: 512,
|
|
height: 512,
|
|
format: 'jpeg',
|
|
formatParameters: {
|
|
quality: 80,
|
|
},
|
|
},
|
|
{
|
|
width: 64,
|
|
height: 64,
|
|
format: 'jpeg',
|
|
formatParameters: {
|
|
compressionLevel: 9,
|
|
},
|
|
},
|
|
];
|
|
await imageService.processImageFile(user, file, images);
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: {
|
|
'picture.large': images[0].image._id,
|
|
'picture.small': images[1].image._id,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async removePhoto (user) {
|
|
const { image: imageService } = this.dtp.services;
|
|
|
|
this.log.info('remove profile photo', { user: user._id });
|
|
user = await this.getUserAccount(user._id);
|
|
if (user.picture.large) {
|
|
await imageService.deleteImage(user.picture.large);
|
|
}
|
|
if (user.picture.small) {
|
|
await imageService.deleteImage(user.picture.small);
|
|
}
|
|
await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } });
|
|
}
|
|
|
|
async blockUser (user, blockedUser) {
|
|
if (user._id.equals(blockedUser._id)) {
|
|
throw new SiteError(406, "You can't block yourself");
|
|
}
|
|
await UserBlock.updateOne(
|
|
{ 'member.user': user._id },
|
|
{
|
|
$addToSet: {
|
|
blockedMembers: {
|
|
userType: blockedUser.type,
|
|
user: blockedUser._id,
|
|
},
|
|
},
|
|
},
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
async unblockUser (user, blockedUser) {
|
|
if (user._id.equals(blockedUser._id)) {
|
|
throw new SiteError(406, "You can't un-block yourself");
|
|
}
|
|
await UserBlock.updateOne(
|
|
{ 'member.user': user._id },
|
|
{
|
|
$removeFromSet: {
|
|
blockedUsers: {
|
|
userType: blockedUser.type,
|
|
user: blockedUser._id,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async updatePassword (user, password) {
|
|
const { crypto: cryptoService } = this.dtp.services;
|
|
|
|
const passwordSalt = uuidv4();
|
|
const passwordHash = cryptoService.maskPassword(passwordSalt, password);
|
|
|
|
this.log.info('updating user password', { userId: user._id });
|
|
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: {
|
|
passwordSalt: passwordSalt,
|
|
password: passwordHash,
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Updates the `lastAnnouncement` field of a User to the `created` date of the
|
|
* specified announcement (for tracking last-seen announcements).
|
|
* @param {User} user The user being updated
|
|
* @param {Announcement} announcement The announcement being seen by the User
|
|
*/
|
|
async setLastAnnouncement (user, announcement) {
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: { lastAnnouncement: announcement.created },
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
slug: 'user',
|
|
name: 'user',
|
|
create: (dtp) => { return new UserService(dtp); },
|
|
};
|