Skip to content
This repository has been archived by the owner on Nov 2, 2024. It is now read-only.

Commit

Permalink
✨ Group notifications (#1163)
Browse files Browse the repository at this point in the history
* Group together notification types

* Add "fromMember" to notifications and add type enums

* Group notifications on type and link

* Remove ping notifications on ping back

* Disallow users to spam notifications or send to themselves

* Fix missing optional chaining

* Modify send notification logic

* Gen graphql

* Fix isues with names

* Add logger to send notification method

* Show avatars of relevant users on notifications

* Add tests for getting notifications

* Fix type of Link onClick to work with Markdown
  • Loading branch information
Macludde authored Sep 5, 2023
1 parent 31c6a68 commit 9de4a76
Show file tree
Hide file tree
Showing 26 changed files with 1,578 additions and 927 deletions.
44 changes: 44 additions & 0 deletions backend/services/core/graphql.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8171,6 +8171,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "groupedIds",
"description": null,
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "UUID",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
Expand Down Expand Up @@ -8203,6 +8223,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "members",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Member",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "message",
"description": null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('notifications', (table) => {
table.uuid('from_member_id')
.unsigned()
.references('members.id')
.onDelete('SET NULL')
.comment('The member which took the action that initiated the notification. Null if not relevant.');
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('notifications', (table) => {
table.dropColumn('from_member_id');
});
}
9 changes: 7 additions & 2 deletions backend/services/core/seeds/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import insertDoors from './helpers/insertDoors';
import insertDoorAccessPolicies from './helpers/insertDoorAccessPolicies';
import insertMailAlias from './helpers/insertMailAlias';
import insertProducts from './helpers/insertProducts';
import { ArticleTag, Alert } from '~/src/types/news';
import { ArticleTag, Alert } from '../src/types/news';
import insertApiAccessPolicies from './helpers/insertApiAccessPolicies';
import insertGoverningDocuments from './helpers/insertGoverningDocuments';
import { insertSubscriptionSettings, insertNotifications } from './helpers/notifications';

// eslint-disable-next-line import/prefer-default-export
export const seed = async (knex: Knex) => {
Expand All @@ -35,7 +36,7 @@ export const seed = async (knex: Knex) => {
await insertExpoTokens(knex);

// Inserts seed entries
const memberIds = await insertMembers(knex);
const memberIds = await insertMembers(knex).then((members) => members.map((member) => member.id));

const committeesIds = await insertCommittees(knex);

Expand Down Expand Up @@ -76,6 +77,10 @@ export const seed = async (knex: Knex) => {

await insertBookingRequests(knex, memberIds, bookableIds);

await insertSubscriptionSettings(knex, memberIds);

await insertNotifications(knex, memberIds);

await insertDoors(knex);

await insertDoorAccessPolicies(knex);
Expand Down
6 changes: 3 additions & 3 deletions backend/services/core/seeds/helpers/insertMembers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Knex } from 'knex';
import { Member } from '~/src/types/database';

export default async function insertMembers(knex: Knex): Promise<string[]> {
return (await knex<Member>('members').insert([
export default async function insertMembers(knex: Knex): Promise<Member[]> {
return (knex<Member>('members').insert([
{
student_id: 'dat15ewi',
first_name: 'Grace',
Expand Down Expand Up @@ -67,5 +67,5 @@ export default async function insertMembers(knex: Knex): Promise<string[]> {
class_year: 2020,
picture_path: 'https://upload.wikimedia.org/wikipedia/commons/9/99/ClaudeShannon_MFO3807.jpg',
},
]).returning('id')).map((v) => v.id);
]).returning('*'));
}
52 changes: 52 additions & 0 deletions backend/services/core/seeds/helpers/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Knex } from 'knex';
import { DEFAULT_SUBSCRIPTION_SETTINGS } from '../../src/shared/notifications';
import { SQLNotification, SubscriptionSetting } from '../../src/types/notifications';

export async function insertSubscriptionSettings(knex: Knex, memberIds: string[]) {
await knex<SubscriptionSetting>('subscription_settings').insert(
memberIds.flatMap((memberId) => (
DEFAULT_SUBSCRIPTION_SETTINGS.map((setting) => ({
...setting,
member_id: memberId,
})))),
);
}

export async function insertNotifications(knex: Knex, memberIds: string[]) {
await knex<SQLNotification>('notifications').insert(memberIds.flatMap((memberId, index) => [{
link: '/news/article/testarticle', // not a valid link
type: 'LIKE',
title: 'Testartikel',
message: 'Alfons Åberg har gillat din nyhet',
member_id: memberId,
from_member_id: memberIds[(index + 1) % memberIds.length],
}, {
link: '/news/article/testarticle', // not a valid link
type: 'LIKE',
title: 'Testartikel',
message: 'Karlsson von Taket har gillat din nyhet',
member_id: memberId,
from_member_id: memberIds[(index + 2) % memberIds.length],
}, {
link: '/news/article/testarticle', // not a valid link
type: 'LIKE',
title: 'Testartikel',
message: 'Findus Pettson har gillat din nyhet',
member_id: memberId,
from_member_id: memberIds[(index + 3) % memberIds.length],
}, {
link: '/news/article/testarticle', // not a valid link
type: 'COMMENT',
title: 'Mumin Trollet har kommenterat på Testartikel',
message: 'Vilken spännande nyhet',
member_id: memberId,
from_member_id: memberIds[(index + 4) % memberIds.length],
}, {
link: '/news/article/testarticle', // not a valid link
type: 'COMMENT',
title: 'Tumme Lisa har kommenterat på Testartikel',
message: 'Som jag väntat på när denna nyheten skulle komma, vad tycker du @Tumme Lisa?',
member_id: memberId,
from_member_id: memberIds[(index + 5) % memberIds.length],
}]));
}
10 changes: 4 additions & 6 deletions backend/services/core/src/datasources/BookingRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as sql from '../types/booking';
// eslint-disable-next-line import/no-cycle
import { DataSources } from '../datasources';
import { Mandate } from '../types/database';
import { NotificationType } from '../shared/notifications';

const logger = createLogger('booking');

Expand Down Expand Up @@ -165,11 +166,7 @@ export default class BookingRequestAPI extends dbUtils.KnexDataSource {
bookingRequest: { endDate: Date, id:string, event:string },
action: string,
) {
if (!ctx?.user?.keycloak_id) {
logger.info('Uninlogged user tried to send notification to KM');
return;
}
const booker = await this.getMemberFromKeycloakId(ctx.user?.keycloak_id);
const booker = await this.getCurrentMember(ctx);
// Get the ids of the km
const kallarMastare = await this.knex<Mandate>('mandates')
.where({ position_id: 'dsek.km.mastare' })
Expand All @@ -180,8 +177,9 @@ export default class BookingRequestAPI extends dbUtils.KnexDataSource {
title: `Booking request ${action}`,
message: `${booker.first_name} ${booker.last_name} has ${action} a booking request: ${bookingRequest.event}`,
link: `/booking?booking=${bookingRequest.id}&endFilter=${bookingRequest.endDate.getTime() + 86_400_000}`, // 24h in ms
type: 'BOOKING_REQUEST',
type: NotificationType.BOOKING_REQUEST,
memberIds: kallarMastare.map((km) => km.member_id),
fromMemberId: booker.id,
});
} else {
logger.error('Källarmästare not found when trying to send notification');
Expand Down
69 changes: 37 additions & 32 deletions backend/services/core/src/datasources/Events.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { ApolloError, UserInputError } from 'apollo-server';
import {
dbUtils, context, UUID, createLogger,
import
{
UUID,
context,
createLogger,
dbUtils,
} from '../shared';
import * as gql from '../types/graphql';
import * as sql from '../types/events';
import { Member } from '../types/database';
import { convertEvent } from '../shared/converters';
import meilisearchAdmin from '../shared/meilisearch';
import { convertMember } from './Member';
import { NotificationType } from '../shared/notifications';
import { Member } from '../types/database';
import * as sql from '../types/events';
import * as gql from '../types/graphql';
import { convertMember, getFullName } from './Member';
import { convertComment } from './News';

const convertTag = (tag: sql.Tag): gql.Tag => {
Expand Down Expand Up @@ -335,7 +340,8 @@ export default class EventAPI extends dbUtils.KnexDataSource {
message: string,
event: sql.Event | gql.Event,
authorId: string,
type: string,
type: NotificationType,
fromMemberId: string,
) {
await this.addNotification(
{
Expand All @@ -344,23 +350,26 @@ export default class EventAPI extends dbUtils.KnexDataSource {
type,
link: `/events/${event.slug || event.id}`,
memberIds: [authorId],
fromMemberId,
},
);
}

private async sendMentionNotifications(
event: gql.Event,
message: string,
commenter: Member,
studentIds: string[],
) {
const students = await this.knex<Member>('members').whereIn('student_id', studentIds);
if (students.length) {
await this.addNotification({
title: 'Du har blivit nämnd i en kommentar',
message: `${commenter.first_name} ${commenter.last_name} har nämnt dig i "${event.title}"`,
title: `${getFullName(commenter)} har nämnt dig i "${event.title}"`,
message,
memberIds: students.map((s) => s.id),
type: 'MENTION',
type: NotificationType.MENTION,
link: `/events/${event.slug || event.id}`,
fromMemberId: commenter.id,
});
} else {
logger.info(`No students found for mentioned student ids: ${studentIds}`);
Expand All @@ -373,40 +382,37 @@ export default class EventAPI extends dbUtils.KnexDataSource {
id: UUID,
): Promise<gql.Maybe<gql.Event>> {
return this.withAccess('event:social', ctx, async () => {
if (!ctx.user) throw new ApolloError('Not logged in');
const user = await this.getMemberFromKeycloakId(ctx.user?.keycloak_id);

if (!user) {
throw new ApolloError(`Could not find member based on keycloak id. Id: ${ctx.user?.keycloak_id}`);
}
const member = await this.getCurrentMember(ctx);

const event = await dbUtils.unique(this.knex<sql.Event>('events').where({ id }));
if (!event) throw new UserInputError(`Event with id did not exist. Id: ${id}`);

try {
await this.knex<sql.MemberEventLink>(table).insert({
event_id: id,
member_id: user.id,
member_id: member.id,
});
} catch {
throw new ApolloError('User is already going to/interested in this event');
}

if (table === 'event_going') {
this.sendNotificationToAuthor(
`${user.first_name} ${user.last_name} is going to your event`,
`${user.first_name} ${user.last_name} is going to your event ${event.title}`,
event.title,
`${getFullName(member)} kommer`,
event,
event.author_id,
'EVENT_GOING',
NotificationType.EVENT_GOING,
member.id,
);
} else if (table === 'event_interested') {
this.sendNotificationToAuthor(
`${user.first_name} ${user.last_name} is interested in your event`,
`${user.first_name} ${user.last_name} is interested in your event ${event.title}`,
event.title,
`${getFullName(member)} är intresserad`,
event,
event.author_id,
'EVENT_INTERESTED',
NotificationType.EVENT_INTERESTED,
member.id,
);
}

Expand Down Expand Up @@ -489,12 +495,7 @@ export default class EventAPI extends dbUtils.KnexDataSource {
content: string,
): Promise<gql.Event> {
return this.withAccess('event:comment', ctx, async () => {
if (!ctx.user) throw new ApolloError('User not logged in');
const me = await this.getMemberFromKeycloakId(ctx.user?.keycloak_id);

if (!me) {
throw new ApolloError(`Could not find member based on keycloak id. Id: ${ctx.user?.keycloak_id}`);
}
const me = await this.getCurrentMember(ctx);

const event = await this.getEvent(ctx, event_id);
if (!event) throw new UserInputError(`Event with id did not exist. Id: ${event_id}`);
Expand All @@ -505,6 +506,8 @@ export default class EventAPI extends dbUtils.KnexDataSource {
content,
published: new Date(),
});
// Replace like the following: [@User Name](/members/stil-id) -> @User Name, [test](https://test.com) -> test
const contentWithoutLinks = content.replaceAll(/\[(.+?)\]\(.+?\)/g, '$1');

const mentionedStudentIds: string[] | undefined = content
.match(/\((\/members[^)]+)\)/g)
Expand All @@ -514,17 +517,19 @@ export default class EventAPI extends dbUtils.KnexDataSource {
if (mentionedStudentIds?.length) {
this.sendMentionNotifications(
event,
contentWithoutLinks,
me,
mentionedStudentIds,
);
}

this.sendNotificationToAuthor(
`${me.first_name} ${me.last_name} commented on your event`,
`${me.first_name} ${me.last_name} commented on your event ${event.title}`,
`${getFullName(me)} har kommenterat på ${event.title}`,
contentWithoutLinks,
event,
event.author.id,
'EVENT_COMMENT',
NotificationType.EVENT_COMMENT,
me.id,
);

return event;
Expand Down
Loading

0 comments on commit 9de4a76

Please sign in to comment.