Skip to content

Commit

Permalink
feat: clear conversations without purpose (#717)
Browse files Browse the repository at this point in the history
* add participant to chat on subcourse join

* mark matchee convo as readonly or writeable

* subcourse purposes

* add chat access enum

* check if members have write rights

* add new chat meta data

* convert meta data as string

* add groupType to metadata, dont stringify strings

* get matchId and subcourseIds for contact

* add mutation for contact chats

* adjust integration-test

* review changes: renamings and types

* Fix tests & type

* functions to remove subcourse and match
mark past subcourse chat
mark empty conversation

* no dissolved or cancelled contact

* get all conversations

* review changes

* add cron job file

* remove useless functions

* add function to flag inactive conversations

* fix async

* add reduce

* remove bbb

* adjust clean up function

* add send system message

* add message on clean up

* remove comment

* review changes

* no contacts from past courses

* every to some

* adjust to yesterday

---------

Co-authored-by: Jonas Wilms <[email protected]>
  • Loading branch information
LomyW and Jonas Wilms authored Jul 20, 2023
1 parent 6d72b6b commit 70e730c
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 17 deletions.
6 changes: 6 additions & 0 deletions common/chat/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pupil as Pupil, student as Student } from '@prisma/client';
import { User } from '../user';
import { ContactReason } from './types';
import { isPupilContact, isStudentContact } from './helper';
import moment from 'moment';

export type UserContactType = {
userID: string;
Expand Down Expand Up @@ -94,6 +95,7 @@ const getMatchContactsForUser = async (user: User): Promise<MatchContactStudent[
};
const getInstructorsForPupilSubcourses = async (pupil: User): Promise<SubcourseContactStudent[]> => {
assert(pupil.pupilId, 'Pupil must have an pupilId');
const yesterday = moment().subtract(1, 'day').toISOString();
const studentsWithSubcourseIds = await prisma.student.findMany({
where: {
subcourse_instructors_student: {
Expand All @@ -102,6 +104,7 @@ const getInstructorsForPupilSubcourses = async (pupil: User): Promise<SubcourseC
allowChatContactParticipants: true,
subcourse_participants_pupil: { some: { pupilId: pupil.pupilId } },
cancelled: false,
lecture: { some: { start: { gte: yesterday } } },
},
},
},
Expand Down Expand Up @@ -138,6 +141,8 @@ const getInstructorsForPupilSubcourses = async (pupil: User): Promise<SubcourseC
};
const getSubcourseParticipantContactForUser = async (student: User): Promise<SubcourseContactPupil[]> => {
assert(student.studentId, 'Student must have an studentId');
const yesterday = moment().subtract(1, 'day').toISOString();

const pupilsWithSubcourseIds = await prisma.pupil.findMany({
where: {
subcourse_participants_pupil: {
Expand All @@ -146,6 +151,7 @@ const getSubcourseParticipantContactForUser = async (student: User): Promise<Sub
allowChatContactParticipants: true,
subcourse_instructors_student: { some: { studentId: student.studentId } },
cancelled: false,
lecture: { some: { start: { gte: yesterday } } },
},
},
},
Expand Down
11 changes: 7 additions & 4 deletions common/chat/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import {
import { User } from '../user';
import { getOrCreateChatUser } from './user';
import { prisma } from '../prisma';
import { AllConversations, ChatAccess, ContactReason, Conversation, ConversationInfos, SystemMessage, TJConversation } from './types';
import { AllConversations, ChatAccess, ChatType, ContactReason, Conversation, ConversationInfos, SystemMessage, TJConversation } from './types';
import { getMyContacts } from './contacts';
import { chat_type } from '@prisma/client';
import systemMessages from './localization';
import { getLogger } from '../logger/logger';

dotenv.config();
const logger = getLogger('Conversation');

const TALKJS_API_URL = `https://api.talkjs.com/v1/${process.env.TALKJS_APP_ID}`;
const TALKJS_CONVERSATION_API_URL = `${TALKJS_API_URL}/conversations`;
Expand Down Expand Up @@ -112,6 +113,7 @@ const getOrCreateOneOnOneConversation = async (

if (isChatReadOnly) {
await markConversationAsWriteable(participantsConversationId);
await sendSystemMessage(systemMessages.de.reactivated, participantsConversationId, SystemMessage.ONE_ON_ONE_REACTIVATE);
}
}

Expand Down Expand Up @@ -267,7 +269,7 @@ async function deleteConversation(conversationId: string): Promise<void> {
}
}

async function addParticipant(user: User, conversationId: string, chatType?: chat_type): Promise<void> {
async function addParticipant(user: User, conversationId: string, chatType?: ChatType): Promise<void> {
const userId = userIdToTalkJsId(user.userID);
try {
const response = await fetch(`${TALKJS_CONVERSATION_API_URL}/${conversationId}/participants/${userId}`, {
Expand All @@ -277,7 +279,7 @@ async function addParticipant(user: User, conversationId: string, chatType?: cha
'Content-Type': 'application/json',
},
body: JSON.stringify({
access: chatType === chat_type.NORMAL ? ChatAccess.READWRITE : ChatAccess.READ,
access: chatType === ChatType.NORMAL ? ChatAccess.READWRITE : ChatAccess.READ,
}),
});
await checkResponseStatus(response);
Expand Down Expand Up @@ -348,6 +350,7 @@ async function markConversationAsReadOnlyForPupils(conversationId: string): Prom
await checkResponseStatus(response);
}
} catch (error) {
logger.error('Could not mark conversation as readonly', error);
throw new Error(error);
}
}
Expand Down
10 changes: 7 additions & 3 deletions common/chat/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createHmac } from 'crypto';
import { Subcourse } from '../../graphql/generated';
import { getPupil, getStudent } from '../../graphql/util';
import { getConversation } from './conversation';
import { ChatMetaData, Conversation, ConversationInfos, TJConversation } from './types';
import { ChatAccess, ChatMetaData, Conversation, ConversationInfos, TJConversation } from './types';
import { MatchContactPupil, MatchContactStudent } from './contacts';

type TalkJSUserId = `${'pupil' | 'student'}_${number}`;
Expand Down Expand Up @@ -130,6 +130,10 @@ const getMatcheeConversation = async (matchees: { studentId: number; pupilId: nu
return { conversation, conversationId };
};

const countChatParticipants = (conversation: Conversation): number => {
return Object.keys(conversation.participants).length;
};

const checkChatMembersAccessRights = (conversation: Conversation): { readWriteMembers: string[]; readMembers: string[] } => {
const readWriteMembers: string[] = [];
const readMembers: string[] = [];
Expand Down Expand Up @@ -168,7 +172,7 @@ const convertConversationInfosToString = (conversationInfos: ConversationInfos):
};

const convertTJConversation = (conversation: TJConversation): Conversation => {
const { id, subject, topicId, photoUrl, welcomeMessages, custom, lastMessage, participants, createdAt } = conversation;
const { id, subject, photoUrl, welcomeMessages, custom, lastMessage, participants, createdAt } = conversation;

const convertedCustom: ChatMetaData = custom
? {
Expand All @@ -183,7 +187,6 @@ const convertTJConversation = (conversation: TJConversation): Conversation => {
return {
id,
subject,
topicId,
photoUrl,
welcomeMessages,
custom: convertedCustom,
Expand All @@ -203,6 +206,7 @@ export {
createChatSignature,
getMatchByMatchees,
createOneOnOneId,
countChatParticipants,
getConversationId,
getMatcheeConversation,
checkChatMembersAccessRights,
Expand Down
4 changes: 2 additions & 2 deletions common/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export enum ContactReason {
CONTACT = 'contact',
}

export type Conversation = Pick<ConversationData, 'topicId' | 'id' | 'subject' | 'photoUrl' | 'welcomeMessages'> & {
export type Conversation = Pick<ConversationData, 'id' | 'subject' | 'photoUrl' | 'welcomeMessages'> & {
custom?: ChatMetaData;
lastMessage?: Message;
participants: {
Expand Down Expand Up @@ -71,5 +71,5 @@ export enum ChatAccess {
}

export type AllConversations = {
data: Conversation[];
data: TJConversation[];
};
12 changes: 4 additions & 8 deletions graphql/subcourse/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,7 @@ export class MutateSubcourseResolver {
await prisma.subcourse_instructors_student.create({ data: { subcourseId, studentId } });
await addGroupAppointmentsOrganizer(subcourseId, studentUserId);
if (subcourse.conversationId) {
await addParticipant(
newInstructorUser,
subcourse.conversationId,
subcourse.groupChatType === ChatType.ANNOUNCEMENT ? ChatType.ANNOUNCEMENT : ChatType.NORMAL
);
await addParticipant(newInstructorUser, subcourse.conversationId, subcourse.groupChatType as ChatType);
}

logger.info(`Student (${studentId}) was added as an instructor to Subcourse(${subcourseId}) by User(${context.user!.userID})`);
Expand Down Expand Up @@ -223,7 +219,7 @@ export class MutateSubcourseResolver {
await joinSubcourse(subcourse, pupil, true);
await addGroupAppointmentsParticipant(subcourseId, user.userID);
if (subcourse.conversationId) {
await addParticipant(user, subcourse.conversationId, subcourse.groupChatType === ChatType.ANNOUNCEMENT ? ChatType.ANNOUNCEMENT : ChatType.NORMAL);
await addParticipant(user, subcourse.conversationId, subcourse.groupChatType as ChatType);
}

return true;
Expand All @@ -241,7 +237,7 @@ export class MutateSubcourseResolver {
const subcourse = await getSubcourse(subcourseId);
await joinSubcourse(subcourse, pupil, false);
await addGroupAppointmentsParticipant(subcourseId, user.userID);
await addParticipant(user, subcourse.conversationId, subcourse.groupChatType === ChatType.ANNOUNCEMENT ? ChatType.ANNOUNCEMENT : ChatType.NORMAL);
await addParticipant(user, subcourse.conversationId, subcourse.groupChatType as ChatType);
return true;
}

Expand Down Expand Up @@ -274,7 +270,7 @@ export class MutateSubcourseResolver {
await joinSubcourse(subcourse, pupil, true);
await addGroupAppointmentsParticipant(subcourseId, user.userID);
if (subcourse.conversationId) {
await addParticipant(user, subcourse.conversationId, subcourse.groupChatType);
await addParticipant(user, subcourse.conversationId, subcourse.groupChatType as ChatType);
}

return true;
Expand Down
5 changes: 5 additions & 0 deletions jobs/manualExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import syncToWebflow from './periodic/sync-to-webflow';
import * as Notification from '../common/notification';
import { runInterestConfirmations } from '../common/match/pool';
import migrateLecturesToAppointment from './migrate-lectures-to-appointment';
import flagInactiveConversationsAsReadonly from './periodic/flag-old-conversations';
import { postStatisticsToSlack } from './slack-statistics';

// Run inside the Web Dyno via GraphQL (mutation _executeJob)
Expand Down Expand Up @@ -50,6 +51,10 @@ export const executeJob = async (job) => {
await migrateLecturesToAppointment();
break;
}
case 'flagOldConversations': {
await flagInactiveConversationsAsReadonly();
break;
}
case 'sendSlackStatistics': {
await postStatisticsToSlack();
break;
Expand Down
100 changes: 100 additions & 0 deletions jobs/periodic/flag-old-conversations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import moment from 'moment';
import { getMatch, getSubcourse } from '../../../graphql/util';
import { getAllConversations, markConversationAsReadOnly, sendSystemMessage } from '../../../common/chat';
import { getLogger } from '../../../common/logger/logger';
import { Lecture } from '../../../graphql/generated';
import { SystemMessage } from '../../../common/chat/types';
import systemMessages from '../../../common/chat/localization';
import { checkChatMembersAccessRights, countChatParticipants } from '../../../common/chat/helper';

const logger = getLogger('FlagOldConversationsAsRO');

enum ConversationType {
GROUP = 'group',
ONE_ON_ONE = 'one_on_one',
}

type conversationsToDeactivate = {
id: string;
conversationType: ConversationType;
};

// one to one chats (if match) whose match was dissolved 30 days ago should be "disabled" (readonly).
// This will allow users to continue writing for another 30 days after match disolving.
async function isActiveMatch(id: number): Promise<boolean> {
const today = moment().endOf('day');
const match = await getMatch(id);
const dissolvedAtPlus30Days = moment(match.dissolvedAt).add(30, 'days');
return !dissolvedAtPlus30Days.isBefore(today);
}

async function isActiveSubcourse(id: number): Promise<boolean> {
const today = moment().endOf('day');
const subcourse = await getSubcourse(id, true);
const isSubcourseCancelled = subcourse.cancelled;

if (isSubcourseCancelled) {
return false;
}

const lastLecutre = subcourse.lecture.sort((a: Lecture, b: Lecture) => moment(a.start).milliseconds() - moment(b.start).milliseconds()).pop();
const lastLecturePlus30Days = moment(lastLecutre.start).add(30, 'days');
const is30DaysBeforeToday = lastLecturePlus30Days.isBefore(today);
return !is30DaysBeforeToday;
}

export default async function flagInactiveConversationsAsReadonly() {
const conversations = await getAllConversations();
const conversationsToFlag: conversationsToDeactivate[] = [];

for (const conversation of conversations.data) {
let shouldMarkAsReadonly: boolean;

// to prevent to flag already deactivated chats we check if the conversation is already readonly (only readMembers)
const countParticipants = countChatParticipants(conversation);
const { readMembers } = checkChatMembersAccessRights(conversation);
const isChatReadOnly = readMembers.length === countParticipants;
if (isChatReadOnly) {
return;
}

if (conversation.custom.subcourse) {
const subcourseIds: number[] = JSON.parse(conversation.custom.subcourse);
const allSubcoursesActive = await Promise.all(subcourseIds.map((id) => isActiveSubcourse(id)));
shouldMarkAsReadonly = allSubcoursesActive.every((active) => active === false);
}
if (conversation.custom.prospectSubcourse) {
const prospectSubcourses: number[] = conversation.custom.prospectSubcourse;
const allProspectSubcoursesActive = await Promise.all(prospectSubcourses.map((id) => isActiveSubcourse(id)));
shouldMarkAsReadonly = allProspectSubcoursesActive.every((active) => active === false);
}

if (conversation.custom.match) {
const match = JSON.parse(conversation.custom.match);
const matchId = match.matchId;
const isMatchActive = await isActiveMatch(matchId);
shouldMarkAsReadonly = !isMatchActive;
}

if (shouldMarkAsReadonly) {
conversationsToFlag.push({
id: conversation.id,
conversationType: conversation.custom.match ? ConversationType.ONE_ON_ONE : ConversationType.GROUP,
});
}
}

if (conversationsToFlag.length > 0) {
for (const convo of conversationsToFlag) {
await markConversationAsReadOnly(convo.id);
if (convo.conversationType === ConversationType.ONE_ON_ONE) {
await sendSystemMessage(systemMessages.de.deactivated, convo.id, SystemMessage.ONE_ON_ONE_OVER);
} else {
await sendSystemMessage(systemMessages.de.deactivated, convo.id, SystemMessage.GROUP_OVER);
}
logger.info('Mark converstation as readonly', { conversationId: convo.id, conversationType: convo.conversationType });
}
} else {
logger.info('No conversation to mark as readonly');
}
}

0 comments on commit 70e730c

Please sign in to comment.