Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions modules/chat/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,25 @@ export default {
format: 'Boolean',
default: false,
},
redirect: {
login_uri: {
doc: 'Where unauthenticated users are redirected before accepting or declining. The hook URL is passed as redirectUri.',
format: 'String',
default: '',
optional: true,
},
accept_uri: {
doc: 'Where users are redirected after accepting an invitation. Supports {roomId} placeholder.',
format: 'String',
default: '',
optional: true,
},
decline_uri: {
doc: 'Where users are redirected after declining an invitation.',
format: 'String',
default: '',
optional: true,
},
},
},
};
161 changes: 113 additions & 48 deletions modules/chat/src/routes/InvitationRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import {
import {
ConduitNumber,
ConduitString,
ConfigController,
RoutingManager,
} from '@conduitplatform/module-tools';
import { ChatRoom, InvitationToken } from '../models/index.js';
import { buildInvitationHookUrl } from '../utils/index.js';
import { isNil } from 'lodash-es';
import { status } from '@grpc/grpc-js';
import { Config } from '../config/index.js';

export class InvitationRoutes {
constructor(
Expand All @@ -24,20 +27,24 @@ export class InvitationRoutes {
declareRoutes() {
this.routingManager.route(
{
path: '/hook/invitations/:answer/:invitationToken',
path: '/hook/chat/invitations/:answer/:invitationToken',
description: `A webhook used to respond to a chat room invitation
requiring the invitation token.`,
action: ConduitRouteActions.GET,
urlParams: {
answer: ConduitString.Required,
invitationToken: ConduitString.Required,
},
middlewares: ['authMiddleware?'],
rateLimit: {
maxRequests: 50,
resetInterval: 3600,
},
},
new ConduitRouteReturnDefinition('InvitationResponse', 'String'),
new ConduitRouteReturnDefinition('InvitationHookResponse', {
result: ConduitString.Optional,
redirect: ConduitString.Optional,
}),
this.answerInvitationFromHook.bind(this),
);
this.routingManager.route(
Expand Down Expand Up @@ -114,36 +121,11 @@ export class InvitationRoutes {
if (isNil(invitationTokenDoc)) {
throw new GrpcError(status.NOT_FOUND, 'Invitation not valid');
}
let message;
const receiver = user._id;
const accepted = answer === 'accept';
const chatRoom = await ChatRoom.getInstance().findOne({
_id: invitationTokenDoc.room as string,
});
if (isNil(chatRoom)) {
throw new GrpcError(status.NOT_FOUND, 'Chat room does not exist');
}
if (!isNil(invitationTokenDoc) && accepted) {
chatRoom.participants.push(user);
await ChatRoom.getInstance().findByIdAndUpdate(chatRoom._id, chatRoom);
message = 'Invitation accepted';
this.grpcSdk.router?.socketPush({
event: 'join-room',
receivers: [user._id],
rooms: [chatRoom._id],
});
this.grpcSdk.router?.socketPush({
event: 'room-joined',
receivers: [user._id],
rooms: [],
data: JSON.stringify({ room: chatRoom._id, roomName: chatRoom.name }),
});
} else {
message = 'Invitation declined';
}

const query = { $and: [{ room: chatRoom._id }, { receiver: receiver }] };
await InvitationToken.getInstance().deleteMany(query);
const message = await this.processInvitationAnswer(
invitationTokenDoc,
answer,
user._id,
);
return message;
}

Expand All @@ -152,15 +134,48 @@ export class InvitationRoutes {
): Promise<UnparsedRouterResponse> {
const { invitationToken, answer } = call.request.params;
const { user } = call.request.context;
const config = ConfigController.getInstance().config;

if (answer !== 'accept' && answer !== 'decline') {
throw new GrpcError(status.INVALID_ARGUMENT, 'Answer must be accept or decline');
}

const invitationTokenDoc: InvitationToken | null =
await InvitationToken.getInstance().findOne({
token: invitationToken,
receiver: user._id,
});
if (isNil(invitationTokenDoc)) {
throw new GrpcError(status.NOT_FOUND, 'Invitation not valid');
}
const roomId: string = invitationTokenDoc?.room as string;

const roomId = invitationTokenDoc.room as string;
const hookUrl = await this.resolveInvitationHookUrl(answer, invitationToken);

if (isNil(user)) {
return this.redirectUnauthenticatedUser(hookUrl, config);
}

if (user._id !== invitationTokenDoc.receiver) {
throw new GrpcError(
status.PERMISSION_DENIED,
'Invitation is not for the current user',
);
}

const message = await this.processInvitationAnswer(
invitationTokenDoc,
answer,
user._id,
);
return this.redirectAfterAnswer(answer, roomId, config, message);
}

private async processInvitationAnswer(
invitationTokenDoc: InvitationToken,
answer: string,
receiverId: string,
): Promise<string> {
const roomId = invitationTokenDoc.room as string;
const chatRoom = await ChatRoom.getInstance()
.findOne({ _id: roomId })
.catch((e: Error) => {
Expand All @@ -169,39 +184,89 @@ export class InvitationRoutes {
if (isNil(chatRoom)) {
throw new GrpcError(status.NOT_FOUND, 'Chat room does not exist');
}
const receiver = invitationTokenDoc.receiver;
if ((chatRoom.participants as string[]).indexOf(receiver as string) !== -1) {
throw new GrpcError(
status.NOT_FOUND,
`User is already a member of target chat room`,
);
}

const receiver = invitationTokenDoc.receiver as string;
const accepted = answer === 'accept';
let message;
if (!isNil(invitationTokenDoc) && accepted) {
(chatRoom.participants as string[]).push(receiver as string);
const alreadyMember = (chatRoom.participants as string[]).includes(receiver);

if (alreadyMember) {
await InvitationToken.getInstance().deleteMany({
$and: [{ room: roomId }, { receiver }],
});
return accepted ? 'Invitation accepted' : 'Invitation declined';
}

let message: string;
if (accepted) {
(chatRoom.participants as string[]).push(receiver);
await ChatRoom.getInstance().findByIdAndUpdate(roomId, chatRoom);
message = 'Invitation accepted';

this.grpcSdk.router?.socketPush({
event: 'join-room',
receivers: [user._id],
receivers: [receiverId],
rooms: [chatRoom._id],
});
this.grpcSdk.router?.socketPush({
event: 'room-joined',
receivers: [user._id],
receivers: [receiverId],
rooms: [],
data: JSON.stringify({ room: chatRoom._id, roomName: chatRoom.name }),
});
} else {
message = 'Invitation declined';
}
const query = { $and: [{ room: roomId }, { receiver: receiver }] };
await InvitationToken.getInstance().deleteMany(query);

await InvitationToken.getInstance().deleteMany({
$and: [{ room: roomId }, { receiver }],
});
return message;
}

private async resolveInvitationHookUrl(
answer: string,
invitationToken: string,
): Promise<string> {
const routerConfig = await this.grpcSdk.config.get('router');
return buildInvitationHookUrl(
routerConfig.hostUrl,
answer as 'accept' | 'decline',
invitationToken,
);
}

private redirectUnauthenticatedUser(
hookUrl: string,
config: Config,
): UnparsedRouterResponse {
const loginUri = config.explicit_room_joins.redirect.login_uri?.replace(/\/$/, '');
if (!loginUri) {
throw new GrpcError(
status.FAILED_PRECONDITION,
'Invitation login redirect is not configured',
);
}
const redirectUrl = new URL(loginUri);
redirectUrl.searchParams.set('redirectUri', hookUrl);
return { redirect: redirectUrl.toString() };
}

private redirectAfterAnswer(
answer: string,
roomId: string,
config: Config,
fallbackMessage: string,
): UnparsedRouterResponse {
const redirectTemplate =
answer === 'accept'
? config.explicit_room_joins.redirect.accept_uri
: config.explicit_room_joins.redirect.decline_uri;
if (!redirectTemplate) {
return fallbackMessage;
}
return { redirect: redirectTemplate.replace(/\{roomId\}/g, roomId) };
}

async cancelInvitation(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
const { user } = call.request.context;
const { id } = call.request.params;
Expand Down
20 changes: 18 additions & 2 deletions modules/chat/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,16 @@ export async function sendInvitations(
);
if (sendEmail) {
const result = { invitationToken, hostUrl: url };
const acceptLink = `${result.hostUrl}/hook/chat/invitations/accept/${result.invitationToken.token}`;
const declineLink = `${result.hostUrl}/hook/chat/invitations/decline/${result.invitationToken.token}`;
const acceptLink = buildInvitationHookUrl(
result.hostUrl,
'accept',
result.invitationToken.token,
);
const declineLink = buildInvitationHookUrl(
result.hostUrl,
'decline',
result.invitationToken.token,
);
const roomName = room.name;
const userName = sender.email;
await grpcSdk
Expand Down Expand Up @@ -95,6 +103,14 @@ export async function sendInvitations(
return 'Invitations sent';
}

export function buildInvitationHookUrl(
hostUrl: string,
answer: 'accept' | 'decline',
invitationToken: string,
) {
return `${hostUrl.replace(/\/$/, '')}/hook/chat/invitations/${answer}/${invitationToken}`;
}

export function populateArray(pop: string | string[] | undefined) {
if (!pop) return pop;
if (typeof pop === 'string' && pop.indexOf(',') !== -1) {
Expand Down
Loading