From d05e37d783d2063115ccdab133b76d061e34f663 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 22 Apr 2024 23:53:26 -0400 Subject: [PATCH] Room invites and enforced memberships; bug fixes --- app/controllers/chat.js | 82 +++++++++++++++++++++ app/controllers/home.js | 1 + app/models/chat-room-invite.js | 6 +- app/models/chat-room.js | 8 +- app/services/chat.js | 130 +++++++++++++++++++++++++++++++-- app/views/admin/user/view.pug | 6 +- app/views/chat/room/invite.pug | 16 +++- app/views/chat/room/view.pug | 5 ++ app/views/home.pug | 85 ++++++++++++++------- client/js/chat-client.js | 28 +++++++ 10 files changed, 323 insertions(+), 44 deletions(-) diff --git a/app/controllers/chat.js b/app/controllers/chat.js index 4b83463..83b7f1a 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -56,6 +56,7 @@ export default class ChatController extends SiteController { router.param('roomId', this.populateRoomId.bind(this)); router.param('messageId', this.populateMessageId.bind(this)); + router.param('inviteId', this.populateInviteId.bind(this)); router.post( '/room/:roomId/message', @@ -64,6 +65,20 @@ export default class ChatController extends SiteController { this.postRoomMessage.bind(this), ); + router.post( + '/room/:roomId/invite/:inviteId', + // limiterService.create(limiterService.config.chat.postRoomInviteAction), + multer.none(), + this.postRoomInviteAction.bind(this), + ); + + router.post( + '/room/:roomId/invite', + // limiterService.create(limiterService.config.chat.postRoomInvite), + multer.none(), + this.postRoomInvite.bind(this), + ); + router.post( '/room/:roomId/settings', requireRoomOwner, @@ -162,6 +177,19 @@ export default class ChatController extends SiteController { } } + async populateInviteId (req, res, next, inviteId) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.invite = await chatService.getInviteById(inviteId); + if (!res.locals.invite) { + throw new SiteError(404, "The chat room invite doesn't exist."); + } + return next(); + } catch (error) { + return next(error); + } + } + async postMessageReaction (req, res) { const { chat: chatService } = this.dtp.services; try { @@ -200,6 +228,60 @@ export default class ChatController extends SiteController { } } + async postRoomInviteAction (req, res) { + const { chat: chatService } = this.dtp.services; + try { + this.log.info('processing invite action', { + inviteId: res.locals.invite._id, + action: req.body.action, + }); + await chatService.processRoomInvite( + req.user, + res.locals.invite, + req.body.action, + ); + + const displayList = this.createDisplayList('invite-result'); + displayList.removeElement(`li[data-invite-id="${res.locals.invite._id}"]`); + displayList.showNotification( + `Room invite ${req.body.action} successfully.`, + 'success', + 'bottom-center', + 3000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to process room invite', { error }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async postRoomInvite (req, res) { + const { chat: chatService } = this.dtp.services; + try { + await chatService.inviteUserToRoom(res.locals.room, req.body); + + const displayList = this.createDisplayList('invite-result'); + displayList.showNotification( + 'Member invited successfully', + 'success', + 'bottom-center', + 3000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to invite user to room', { error }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postRoomSettings (req, res, next) { const { chat: chatService } = this.dtp.services; try { diff --git a/app/controllers/home.js b/app/controllers/home.js index 17c8eee..f5de677 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -47,6 +47,7 @@ export default class HomeController extends SiteController { res.locals.pagination = this.getPaginationParameters(req, 10); res.locals.memberRooms = await chatService.getRoomsForMember(req.user, res.locals.pagination); res.locals.memberRooms = res.locals.memberRooms.filter((room) => !room.owner._id.equals(req.user._id)); + res.locals.invites = await chatService.getInvitationsForUser(req.user, { skip: 0, cpp: 5 }); res.render('home'); } catch (error) { diff --git a/app/models/chat-room-invite.js b/app/models/chat-room-invite.js index c0e94aa..29ba522 100644 --- a/app/models/chat-room-invite.js +++ b/app/models/chat-room-invite.js @@ -10,8 +10,8 @@ const Schema = mongoose.Schema; const INVITE_STATUS_LIST = ['new', 'viewed', 'accepted', 'rejected']; const InviteeSchema = new Schema({ - user: { }, // pick up here - email: { }, + user: { type: Schema.ObjectId, ref: 'User' }, + email: { type: String }, }); const ChatRoomInviteSchema = new Schema({ @@ -20,7 +20,7 @@ const ChatRoomInviteSchema = new Schema({ owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, room: { type: Schema.ObjectId, required: true, index: 1, ref: 'ChatRoom' }, member: { type: InviteeSchema, required: true }, - status: { type: String, enum: INVITE_STATUS_LIST, required: true }, + status: { type: String, enum: INVITE_STATUS_LIST, default: 'new', required: true }, message: { type: String }, }); diff --git a/app/models/chat-room.js b/app/models/chat-room.js index b5df023..2d9de01 100644 --- a/app/models/chat-room.js +++ b/app/models/chat-room.js @@ -16,10 +16,10 @@ const ChatRoomSchema = new Schema({ name: { type: String, required: true }, topic: { type: String }, capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY }, - invites: { type: [Schema.ObjectId], select: false }, - members: { type: [Schema.ObjectId], select: false }, - present: { type: [Schema.ObjectId], select: false }, - banned: { type: [Schema.ObjectId], select: false }, + invites: { type: [Schema.ObjectId], select: false, ref: 'ChatRoomInvite' }, + members: { type: [Schema.ObjectId], select: false, ref: 'User' }, + present: { type: [Schema.ObjectId], select: false, ref: 'User' }, + banned: { type: [Schema.ObjectId], select: false, ref: 'User' }, settings: { expireDays: { type: Number, default: 7, min: 1, max: 30, required: true }, }, diff --git a/app/services/chat.js b/app/services/chat.js index 5d04db8..df446b2 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -8,10 +8,13 @@ import mongoose from 'mongoose'; const ChatRoom = mongoose.model('ChatRoom'); const ChatMessage = mongoose.model('ChatMessage'); const ChatRoomInvite = mongoose.model('ChatRoomInvite'); +const User = mongoose.model('User'); import numeral from 'numeral'; import dayjs from 'dayjs'; +import { v4 as uuidv4 } from 'uuid'; + import { SiteService, SiteError } from '../../lib/site-lib.js'; import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; @@ -41,8 +44,10 @@ export default class ChatService extends SiteService { { path: 'present', select: userService.USER_SELECT, + populate: userService.populateUser, }, ]; + this.populateChatMessage = [ { path: 'channel', @@ -75,6 +80,21 @@ export default class ChatService extends SiteService { ], }, ]; + + this.populateChatRoomInvite = [ + { + path: 'owner', + select: userService.USER_SELECT, + }, + { + path: 'room', + populate: this.populateChatRoom, + }, + { + path: 'member.user', + select: userService.USER_SELECT, + }, + ]; } async createRoom (owner, roomDefinition) { @@ -127,6 +147,97 @@ export default class ChatService extends SiteService { await ChatRoom.deleteOne({ _id: room._id }); } + async inviteUserToRoom (room, inviteDefinition) { + const { text: textService } = this.dtp.services; + const NOW = new Date(); + + inviteDefinition.usernameOrEmail = inviteDefinition.usernameOrEmail.trim().toLowerCase(); + + const invitee = await User.findOne({ + $or: [ + { username_lc: inviteDefinition.usernameOrEmail }, + { email: inviteDefinition.usernameOrEmail }, + ], + }).lean(); + if (invitee) { + throw new SiteError(400, 'User already invited to room'); + } + + const invite = new ChatRoomInvite(); + invite.created = NOW; + invite.token = uuidv4(); + invite.owner = room.owner._id; + invite.room = room._id; + + let userAccount = await User.findOne({ email: inviteDefinition.usernameOrEmail }); + if (!userAccount) { + userAccount = await User.findOne({ username_lc: inviteDefinition.usernameOrEmail.toLowerCase() }); + } + + if (userAccount) { + invite.member = { user: userAccount._id }; + } else { + invite.member = { email: inviteDefinition.usernameOrEmail.trim() }; + } + + invite.status = 'new'; + if (inviteDefinition.message) { + invite.message = textService.filter(inviteDefinition.message); + } + + this.log.info('inviting user to chat room', { + user: inviteDefinition.usernameOrEmail, + room: { + _id: room._id, + name: room.name, + }, + }); + + await invite.save(); + + return invite.toObject(); + } + + async getInvitationsForUser (user, pagination) { + const invites = await ChatRoomInvite + .find({ 'member.user': user._id, status: 'new' }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoomInvite) + .lean(); + return invites; + } + + async getInviteById (inviteId) { + const invite = await ChatRoomInvite + .findOne({ _id: inviteId }) + .populate(this.populateChatRoomInvite) + .lean(); + return invite; + } + + async processRoomInvite (user, invite, action) { + await ChatRoom.updateOne( + { _id: invite.room._id }, + { + $addToSet: { members: user._id }, + }, + ); + + await ChatRoomInvite.updateOne( + { _id: invite._id }, + { + $set: { + 'member.user': user._id, + status: action, + }, + $unset: { + 'member.email': 1, + }, + }, + ); + } + async joinRoom (room, user) { const roomData = await ChatRoom.findOne({ _id: room._id, banned: user._id }).lean(); if (roomData) { @@ -176,7 +287,13 @@ export default class ChatService extends SiteService { * joined the room. */ const displayList = this.createDisplayList('chat-control'); - displayList.removeElement(`ul#present-members li[data-member-id="${member._id}"]`); + displayList.removeElement( + `ul#chat-active-members li[data-member-id="${member._id}"]`, + ); + displayList.removeElement( + `ul#chat-idle-members li[data-member-id="${member._id}"]`, + ); + displayList.addElement( `ul#chat-active-members[data-room-id="${room._id}"]`, 'afterBegin', @@ -188,17 +305,17 @@ export default class ChatService extends SiteService { numeral(roomData.stats.presentCount).format('0,0'), ); - const systemMessage = { - created: NOW.toISOString(), - content: `${member.displayName || member.username} has entered the room.`, - }; + // const systemMessage = { + // created: NOW.toISOString(), + // content: `${member.displayName || member.username} has entered the room.`, + // }; this.dtp.emitter .to(room._id.toString()) .emit('chat-control', { displayList, audio: { playSound: 'chat-room-connect' }, - systemMessages: [systemMessage], + // systemMessages: [systemMessage], }); } @@ -468,6 +585,7 @@ export default class ChatService extends SiteService { async getRoomById (roomId) { const room = await ChatRoom .findOne({ _id: roomId }) + .select('+present') .populate(this.populateChatRoom) .lean(); return room; diff --git a/app/views/admin/user/view.pug b/app/views/admin/user/view.pug index ab3fe51..f00ffc9 100644 --- a/app/views/admin/user/view.pug +++ b/app/views/admin/user/view.pug @@ -12,10 +12,10 @@ block admin-content .uk-width-expand .uk-margin - .uk-text-lead= user.displayName + .uk-text-lead= userAccount.displayName div(uk-grid).uk-grid-small.uk-grid-divider - .uk-width-auto @#{user.username} - .uk-width-auto= user.email + .uk-width-auto @#{userAccount.username} + .uk-width-auto= userAccount.email .uk-width-auto Created #{dayjs(userAccount.created).fromNow()} on #{dayjs(userAccount.created).format('MMMM D, YYYY')} .uk-card-body diff --git a/app/views/chat/room/invite.pug b/app/views/chat/room/invite.pug index 3d35d99..2a006c3 100644 --- a/app/views/chat/room/invite.pug +++ b/app/views/chat/room/invite.pug @@ -1,11 +1,21 @@ button(type="button", uk-close).uk-modal-close-default -form(method="POST", action=`/chat/room/${room._id}/invite`, style="background: none;").uk-form +form( + method="POST", + action=`/chat/room/${room._id}/invite`, + onsubmit="return dtp.app.submitForm(event, 'invite room member');", +).uk-form .uk-card.uk-card-secondary.uk-card-small .uk-card-header h1.uk-card-title Invite New Member + .uk-card-body - label(for="username").uk-form-label Username - input(id="username", name="username", type="text", placeholder="Enter username").uk-input + .uk-margin + label(for="username").uk-form-label Username + input(id="username", name="usernameOrEmail", type="text", placeholder="Enter username or email address").uk-input + .uk-margin + label(for="message").uk-form-Label Message + textarea(id="message", name="message", rows="3", placeholder="Enter message to new member").uk-textarea.uk-resize-vertical= "Join my room!" + .uk-card-footer.uk-flex.uk-flex-right .uk-width-auto button(type="submit").uk-button.uk-button-primary.uk-border-rounded Invite Member \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug index 23cab33..2d643cb 100644 --- a/app/views/chat/room/view.pug +++ b/app/views/chat/room/view.pug @@ -7,6 +7,7 @@ block vendorcss block view-content include ../components/message + include ../components/member-list-item mixin renderLiveMember (member) div(data-user-id= member._id, data-username= member.username).stage-live-member @@ -37,6 +38,10 @@ block view-content .chat-present-count.uk-text-small --- .sidebar-panel ul(id="chat-active-members", data-room-id= room._id).uk-list.uk-list-collapse + each presentMember of room.present + - + var isHost = room.owner._id.equals(user._id) + +renderChatMemberListItem(room, presentMember, { isHost: isHost, isGuest: !isHost }) .chat-stage-header Idle Members .sidebar-panel diff --git a/app/views/home.pug b/app/views/home.pug index 68c4378..723991c 100644 --- a/app/views/home.pug +++ b/app/views/home.pug @@ -20,30 +20,65 @@ block view-content section.uk-section.uk-section-default .uk-container - .uk-margin-medium - div(uk-grid) - .uk-width-expand - .uk-text-lead YOUR ROOMS - .uk-width-auto - a(href="/chat/room/create").uk-button.uk-button-default.uk-border-rounded - span - i.fas.fa-plus - span.uk-margin-small-left Create Room + div(uk-grid) + if Array.isArray(invites) && (invites.length > 0) + div(class="uk-width-1-1 uk-width-1-3@m") + .uk-margin-medium + .uk-background-secondary.uk-light.uk-padding-small.uk-border-rounded + h4 Room Invites + ul.uk-list + each invite in invites + li(data-invite-id= invite._id) + div(uk-grid).uk-grid-small + .uk-width-expand + .uk-text-bold.uk-text-truncate= invite.room.name + .uk-text-small @#{invite.owner.username} #{dayjs(invite.created).fromNow()} - if (Array.isArray(ownerRooms) && (ownerRooms.length > 0)) - ul.uk-list - each room in ownerRooms - li.uk-list-divider - +renderRoomListEntry(room) - else - p You don't own any rooms. + .uk-width-auto + a( + href="", + data-room-id= invite.room._id, + data-invite-id= invite._id, + data-invite-action= "accepted", + onclick="return dtp.app.processRoomInvite(event)", + ).uk-text-success + i.fa-solid.fa-check - .uk-margin-medium - .uk-text-lead ROOM MEMBERSHIPS - if (Array.isArray(memberRooms) && (memberRooms.length > 0)) - ul.uk-list - each room in memberRooms - li.uk-list-divider - +renderRoomListEntry(room) - else - p You haven't joined any rooms that you don't own. \ No newline at end of file + .uk-width-auto + a( + href="", + data-room-id= invite.room._id, + data-invite-id= invite._id, + data-invite-action= "rejected", + onclick="return dtp.app.processRoomInvite(event)", + ).uk-text-danger + i.fa-solid.fa-times + + .uk-width-expand + .uk-margin-medium + div(uk-grid) + .uk-width-expand + .uk-text-lead YOUR ROOMS + .uk-width-auto + a(href="/chat/room/create").uk-button.uk-button-default.uk-border-rounded + span + i.fas.fa-plus + span.uk-margin-small-left Create Room + + if (Array.isArray(ownerRooms) && (ownerRooms.length > 0)) + ul.uk-list + each room in ownerRooms + li.uk-list-divider + +renderRoomListEntry(room) + else + p You don't own any rooms. + + .uk-margin-medium + .uk-text-lead ROOM MEMBERSHIPS + if (Array.isArray(memberRooms) && (memberRooms.length > 0)) + ul.uk-list + each room in memberRooms + li.uk-list-divider + +renderRoomListEntry(room) + else + p You haven't joined any rooms that you don't own. \ No newline at end of file diff --git a/client/js/chat-client.js b/client/js/chat-client.js index a7f9fc8..788572d 100644 --- a/client/js/chat-client.js +++ b/client/js/chat-client.js @@ -863,4 +863,32 @@ export class ChatApp extends DtpApp { this.log.info('onEmojiPicked', 'An emoji has been selected', { event }); this.emojiPickerTarget.value += event.unicode; } + + async processRoomInvite (event) { + const target = event.currentTarget || event.target; + + event.preventDefault(); + event.stopPropagation(); + + const roomId = target.getAttribute('data-room-id'); + const inviteId = target.getAttribute('data-invite-id'); + const action = target.getAttribute('data-invite-action'); + + try { + const url = `/chat/room/${roomId}/invite/${inviteId}`; + const payload = JSON.stringify({ action }); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': payload.length, + }, + body: payload, + }); + return this.processResponse(response); + } catch (error) { + this.log.error('processRoomInvite', 'failed to process room invite', { error }); + UIkit.modal.alert(`Failed to process room invite: ${error.message}`); + } + } } \ No newline at end of file