Browse Source

Room invites and enforced memberships; bug fixes

develop
Rob Colbert 1 year ago
parent
commit
d05e37d783
  1. 82
      app/controllers/chat.js
  2. 1
      app/controllers/home.js
  3. 6
      app/models/chat-room-invite.js
  4. 8
      app/models/chat-room.js
  5. 130
      app/services/chat.js
  6. 6
      app/views/admin/user/view.pug
  7. 16
      app/views/chat/room/invite.pug
  8. 5
      app/views/chat/room/view.pug
  9. 85
      app/views/home.pug
  10. 28
      client/js/chat-client.js

82
app/controllers/chat.js

@ -56,6 +56,7 @@ export default class ChatController extends SiteController {
router.param('roomId', this.populateRoomId.bind(this)); router.param('roomId', this.populateRoomId.bind(this));
router.param('messageId', this.populateMessageId.bind(this)); router.param('messageId', this.populateMessageId.bind(this));
router.param('inviteId', this.populateInviteId.bind(this));
router.post( router.post(
'/room/:roomId/message', '/room/:roomId/message',
@ -64,6 +65,20 @@ export default class ChatController extends SiteController {
this.postRoomMessage.bind(this), 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( router.post(
'/room/:roomId/settings', '/room/:roomId/settings',
requireRoomOwner, 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) { async postMessageReaction (req, res) {
const { chat: chatService } = this.dtp.services; const { chat: chatService } = this.dtp.services;
try { 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) { async postRoomSettings (req, res, next) {
const { chat: chatService } = this.dtp.services; const { chat: chatService } = this.dtp.services;
try { try {

1
app/controllers/home.js

@ -47,6 +47,7 @@ export default class HomeController extends SiteController {
res.locals.pagination = this.getPaginationParameters(req, 10); res.locals.pagination = this.getPaginationParameters(req, 10);
res.locals.memberRooms = await chatService.getRoomsForMember(req.user, res.locals.pagination); 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.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'); res.render('home');
} catch (error) { } catch (error) {

6
app/models/chat-room-invite.js

@ -10,8 +10,8 @@ const Schema = mongoose.Schema;
const INVITE_STATUS_LIST = ['new', 'viewed', 'accepted', 'rejected']; const INVITE_STATUS_LIST = ['new', 'viewed', 'accepted', 'rejected'];
const InviteeSchema = new Schema({ const InviteeSchema = new Schema({
user: { }, // pick up here user: { type: Schema.ObjectId, ref: 'User' },
email: { }, email: { type: String },
}); });
const ChatRoomInviteSchema = new Schema({ const ChatRoomInviteSchema = new Schema({
@ -20,7 +20,7 @@ const ChatRoomInviteSchema = new Schema({
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
room: { type: Schema.ObjectId, required: true, index: 1, ref: 'ChatRoom' }, room: { type: Schema.ObjectId, required: true, index: 1, ref: 'ChatRoom' },
member: { type: InviteeSchema, required: true }, 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 }, message: { type: String },
}); });

8
app/models/chat-room.js

@ -16,10 +16,10 @@ const ChatRoomSchema = new Schema({
name: { type: String, required: true }, name: { type: String, required: true },
topic: { type: String }, topic: { type: String },
capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY }, capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY },
invites: { type: [Schema.ObjectId], select: false }, invites: { type: [Schema.ObjectId], select: false, ref: 'ChatRoomInvite' },
members: { type: [Schema.ObjectId], select: false }, members: { type: [Schema.ObjectId], select: false, ref: 'User' },
present: { type: [Schema.ObjectId], select: false }, present: { type: [Schema.ObjectId], select: false, ref: 'User' },
banned: { type: [Schema.ObjectId], select: false }, banned: { type: [Schema.ObjectId], select: false, ref: 'User' },
settings: { settings: {
expireDays: { type: Number, default: 7, min: 1, max: 30, required: true }, expireDays: { type: Number, default: 7, min: 1, max: 30, required: true },
}, },

130
app/services/chat.js

@ -8,10 +8,13 @@ import mongoose from 'mongoose';
const ChatRoom = mongoose.model('ChatRoom'); const ChatRoom = mongoose.model('ChatRoom');
const ChatMessage = mongoose.model('ChatMessage'); const ChatMessage = mongoose.model('ChatMessage');
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); const ChatRoomInvite = mongoose.model('ChatRoomInvite');
const User = mongoose.model('User');
import numeral from 'numeral'; import numeral from 'numeral';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { v4 as uuidv4 } from 'uuid';
import { SiteService, SiteError } from '../../lib/site-lib.js'; import { SiteService, SiteError } from '../../lib/site-lib.js';
import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js';
@ -41,8 +44,10 @@ export default class ChatService extends SiteService {
{ {
path: 'present', path: 'present',
select: userService.USER_SELECT, select: userService.USER_SELECT,
populate: userService.populateUser,
}, },
]; ];
this.populateChatMessage = [ this.populateChatMessage = [
{ {
path: 'channel', 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) { async createRoom (owner, roomDefinition) {
@ -127,6 +147,97 @@ export default class ChatService extends SiteService {
await ChatRoom.deleteOne({ _id: room._id }); 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) { async joinRoom (room, user) {
const roomData = await ChatRoom.findOne({ _id: room._id, banned: user._id }).lean(); const roomData = await ChatRoom.findOne({ _id: room._id, banned: user._id }).lean();
if (roomData) { if (roomData) {
@ -176,7 +287,13 @@ export default class ChatService extends SiteService {
* joined the room. * joined the room.
*/ */
const displayList = this.createDisplayList('chat-control'); 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( displayList.addElement(
`ul#chat-active-members[data-room-id="${room._id}"]`, `ul#chat-active-members[data-room-id="${room._id}"]`,
'afterBegin', 'afterBegin',
@ -188,17 +305,17 @@ export default class ChatService extends SiteService {
numeral(roomData.stats.presentCount).format('0,0'), numeral(roomData.stats.presentCount).format('0,0'),
); );
const systemMessage = { // const systemMessage = {
created: NOW.toISOString(), // created: NOW.toISOString(),
content: `${member.displayName || member.username} has entered the room.`, // content: `${member.displayName || member.username} has entered the room.`,
}; // };
this.dtp.emitter this.dtp.emitter
.to(room._id.toString()) .to(room._id.toString())
.emit('chat-control', { .emit('chat-control', {
displayList, displayList,
audio: { playSound: 'chat-room-connect' }, audio: { playSound: 'chat-room-connect' },
systemMessages: [systemMessage], // systemMessages: [systemMessage],
}); });
} }
@ -468,6 +585,7 @@ export default class ChatService extends SiteService {
async getRoomById (roomId) { async getRoomById (roomId) {
const room = await ChatRoom const room = await ChatRoom
.findOne({ _id: roomId }) .findOne({ _id: roomId })
.select('+present')
.populate(this.populateChatRoom) .populate(this.populateChatRoom)
.lean(); .lean();
return room; return room;

6
app/views/admin/user/view.pug

@ -12,10 +12,10 @@ block admin-content
.uk-width-expand .uk-width-expand
.uk-margin .uk-margin
.uk-text-lead= user.displayName .uk-text-lead= userAccount.displayName
div(uk-grid).uk-grid-small.uk-grid-divider div(uk-grid).uk-grid-small.uk-grid-divider
.uk-width-auto @#{user.username} .uk-width-auto @#{userAccount.username}
.uk-width-auto= user.email .uk-width-auto= userAccount.email
.uk-width-auto Created #{dayjs(userAccount.created).fromNow()} on #{dayjs(userAccount.created).format('MMMM D, YYYY')} .uk-width-auto Created #{dayjs(userAccount.created).fromNow()} on #{dayjs(userAccount.created).format('MMMM D, YYYY')}
.uk-card-body .uk-card-body

16
app/views/chat/room/invite.pug

@ -1,11 +1,21 @@
button(type="button", uk-close).uk-modal-close-default 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.uk-card-secondary.uk-card-small
.uk-card-header .uk-card-header
h1.uk-card-title Invite New Member h1.uk-card-title Invite New Member
.uk-card-body .uk-card-body
label(for="username").uk-form-label Username .uk-margin
input(id="username", name="username", type="text", placeholder="Enter username").uk-input 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-card-footer.uk-flex.uk-flex-right
.uk-width-auto .uk-width-auto
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Invite Member button(type="submit").uk-button.uk-button-primary.uk-border-rounded Invite Member

5
app/views/chat/room/view.pug

@ -7,6 +7,7 @@ block vendorcss
block view-content block view-content
include ../components/message include ../components/message
include ../components/member-list-item
mixin renderLiveMember (member) mixin renderLiveMember (member)
div(data-user-id= member._id, data-username= member.username).stage-live-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 --- .chat-present-count.uk-text-small ---
.sidebar-panel .sidebar-panel
ul(id="chat-active-members", data-room-id= room._id).uk-list.uk-list-collapse 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 .chat-stage-header Idle Members
.sidebar-panel .sidebar-panel

85
app/views/home.pug

@ -20,30 +20,65 @@ block view-content
section.uk-section.uk-section-default section.uk-section.uk-section-default
.uk-container .uk-container
.uk-margin-medium div(uk-grid)
div(uk-grid) if Array.isArray(invites) && (invites.length > 0)
.uk-width-expand div(class="uk-width-1-1 uk-width-1-3@m")
.uk-text-lead YOUR ROOMS .uk-margin-medium
.uk-width-auto .uk-background-secondary.uk-light.uk-padding-small.uk-border-rounded
a(href="/chat/room/create").uk-button.uk-button-default.uk-border-rounded h4 Room Invites
span ul.uk-list
i.fas.fa-plus each invite in invites
span.uk-margin-small-left Create Room 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)) .uk-width-auto
ul.uk-list a(
each room in ownerRooms href="",
li.uk-list-divider data-room-id= invite.room._id,
+renderRoomListEntry(room) data-invite-id= invite._id,
else data-invite-action= "accepted",
p You don't own any rooms. onclick="return dtp.app.processRoomInvite(event)",
).uk-text-success
i.fa-solid.fa-check
.uk-margin-medium .uk-width-auto
.uk-text-lead ROOM MEMBERSHIPS a(
if (Array.isArray(memberRooms) && (memberRooms.length > 0)) href="",
ul.uk-list data-room-id= invite.room._id,
each room in memberRooms data-invite-id= invite._id,
li.uk-list-divider data-invite-action= "rejected",
+renderRoomListEntry(room) onclick="return dtp.app.processRoomInvite(event)",
else ).uk-text-danger
p You haven't joined any rooms that you don't own. 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.

28
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.log.info('onEmojiPicked', 'An emoji has been selected', { event });
this.emojiPickerTarget.value += event.unicode; 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}`);
}
}
} }
Loading…
Cancel
Save