Skip to content

Commit

Permalink
[SOA-38] Enable Post Mentions (#41)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): add user.index route, controller and service action

* styles πŸ’… (wip): progress on add autocomplete interface

* styles πŸ’… : create highlight parsing input

* refactor ✨ (ui): replace avatar comps for generic UserAvatar

* feat 🎸 : more progress

* feat 🎸 (be): serialise mentions

* styles πŸ’… : improve highlighted_input

* styles πŸ’… : parse mentions on post_card content

* feat 🎸 (be): notify post content mentioned users via hook

* test πŸ§ͺ (unit): add tests for post mention notification event

* feat 🎸 (be): parse notification serialized template

* styles πŸ’… : improve highlighted content overlay placement

* refactor ✨ : small nits and renames

* test πŸ§ͺ (unit): add user_service/search action test

* fix βœ… : override user on mention notification
  • Loading branch information
mariadriana-deemaze authored Nov 28, 2024
1 parent 45448b8 commit 305a7ad
Show file tree
Hide file tree
Showing 28 changed files with 842 additions and 88 deletions.
1 change: 1 addition & 0 deletions adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default defineConfig({
file: () => import('#start/repl'),
environment: ['repl'],
},
() => import('#start/events'),
],

/*
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/users_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { PageObject } from '@adonisjs/inertia/types'
export default class UsersController {
constructor(private readonly service: UserService) {}

async index(ctx: HttpContext) {
const searchTerm = ctx.request.qs().search || ''
const page = ctx.request.qs().page || 1
return this.service.search(searchTerm, { page, limit: 5 })
}

async show(ctx: HttpContext): Promise<
| string
| PageObject<{
Expand Down
1 change: 1 addition & 0 deletions app/enums/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export enum NotificationType {
UserPostReportedNotification = 'UserPostReportedNotification',
PostReportingUserStatusNotification = 'PostReportingUserStatusNotification',
PostOwnerReactionNotification = 'PostOwnerReactionNotification',
PostMentionNotification = 'PostMentionNotification',
}
1 change: 1 addition & 0 deletions app/interfaces/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface LinkResponse {
export interface PostResponse extends BaseEntity {
id: UUID
content: string
mentions: Record<string, UserResponse>
status: PostStatus
attachments: {
images: AttachmentResponse[]
Expand Down
30 changes: 30 additions & 0 deletions app/listeners/trigger_post_mention_notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Post from '#models/post'
import User from '#models/user'
import PostMentionNotification from '#notifications/post_mention_notification'
import { NotificationType } from '#enums/notification'

export default class TriggerPostMentionNotification {
async handle([mentions, post]: [string[], Post]) {
const notifiables = await this.notifiables(mentions)
for (const notifiable of notifiables) {
const userNotifications = await notifiable.unreadNotifications()
const prev = userNotifications.filter(
(notification) =>
notification.data.type === NotificationType.PostMentionNotification &&
notification.data.postId === post.id
)
if (prev.length > 0) return
notifiable.notify(new PostMentionNotification(post))
}
}

async notifiables(mentions: string[]): Promise<User[]> {
const notifiables: User[] = []
if (mentions.length === 0) return notifiables
for (const mention of mentions) {
const user = await User.findBy('username', mention)
if (user) notifiables.push(user)
}
return notifiables
}
}
18 changes: 15 additions & 3 deletions app/models/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ import {
BaseModel,
beforeDelete,
beforeSave,
beforeUpdate,
belongsTo,
column,
computed,
hasMany,
scope,
} from '@adonisjs/lucid/orm'
import User from '#models/user'
import { extractFirstLink, sanitizePostContent } from '#utils/index'
import { extractFirstLink, REGEX, sanitizePostContent } from '#utils/index'
import PostReaction from '#models/post_reaction'
import PostReport from '#models/post_report'
import { PostStatus } from '#enums/post'
import emitter from '@adonisjs/core/services/emitter'
import type { UUID } from 'node:crypto'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'

Expand Down Expand Up @@ -55,10 +55,22 @@ export default class Post extends BaseModel {
return extractFirstLink(this.content)
}

@computed()
get matches(): Map<string, string[]> {
const mapped = new Map<string, string[]>()
const possibleMentions =
this.content.match(new RegExp(REGEX.MENTIONS))?.map((m) => m.replace('@', '')) || []
mapped.set('@', possibleMentions)
return mapped
}

@beforeSave()
@beforeUpdate()
static sanitizeContent(post: Post) {
post.content = sanitizePostContent(post.content)
const mentions = post.matches.get('@')
// TODO: Future concept
// const tags = post.matches.get('#')
if (mentions) emitter.emit('post:mention', [mentions, post])
}

@beforeDelete()
Expand Down
36 changes: 36 additions & 0 deletions app/notifications/post_mention_notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NotificationChannelName, NotificationContract } from '@osenco/adonisjs-notifications/types'
import { PostMentionNotificationData } from '@osenco/adonisjs-notifications/types'
import { NotificationType } from '#enums/notification'
import Post from '#models/post'
import type User from '#models/user'

export default class PostMentionNotification implements NotificationContract<User> {
private post: Post

protected subject = ''
protected message = ''

constructor(post: Post) {
this.post = post
this.#templateData()
}

via(): NotificationChannelName | Array<NotificationChannelName> {
return 'database'
}

toDatabase(): PostMentionNotificationData {
return {
type: NotificationType.PostMentionNotification,
userId: this.post.userId,
postId: this.post.id,
title: this.subject,
message: this.message,
}
}

#templateData() {
this.subject = `:authorFullName has mentioned you on their your post`
this.message = `":content"`
}
}
20 changes: 20 additions & 0 deletions app/services/posts_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { ModelObject } from '@adonisjs/lucid/types/model'
import type { HttpContext } from '@adonisjs/core/http'
import type { UUID } from 'node:crypto'
import { UserService } from '#services/user_service'
import { UserResponse } from '#interfaces/user'
import User from '#models/user'

export default class PostsService {
private readonly userService: UserService
Expand Down Expand Up @@ -134,6 +136,22 @@ export default class PostsService {
return this.attachmentService.deleteMany(AttachmentModel.POST, id)
}

/**
* Parse content in search of other user mentions, and returns matches.
*/
async processMentions(post: Post): Promise<Map<string, UserResponse>> {
const matches = post.matches.get('@') || []
const result: Map<string, UserResponse> = new Map()
for (const username of matches) {
const user = await User.findBy('username', username)
if (user) {
const serialized = await this.userService.serialize(user)
result.set(user.username, serialized)
}
}
return result
}

/**
* Handles the process on serializing the post data, and aggregating it's many associations.
*/
Expand All @@ -142,6 +160,7 @@ export default class PostsService {
const user = await this.userService.serialize(post.user)
const attachments = await this.attachmentService.findMany(AttachmentModel.POST, post.id)
const link = await this.linkService.show(post.link)
const mentions = await this.processMentions(post)

let accumulator: Record<PostReactionType, number> = {
[PostReactionType.LIKE]: 0,
Expand All @@ -162,6 +181,7 @@ export default class PostsService {
const resource: PostResponse = {
id: data.id,
content: data.content,
mentions: Object.fromEntries(mentions),
status: data.status,
user,
link,
Expand Down
75 changes: 58 additions & 17 deletions app/services/user_notification_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,32 +55,21 @@ export default class UserNotificationService {
updatedAt: json.updatedAt,
}

let user = users.get(notification.notifiableId)
let user = await this.getSetMap<UserResponse>('user', notification.notifiableId, users)
if (!user) {
const serialized = await this.userService.findOne(notification.data.userId)
if (!serialized) {
logger.error(`Attempted to notify. UserId ${notification.data.userId} not found.`)
break
}
user = serialized
users.set(serialized.id, serialized)
logger.error(`Attempted to notify. UserId ${notification.data.userId} not found.`)
break
}

serializedNotification.data = { ...json.data, user }

// Replace template strings
switch (notification.data.type) {
case NotificationType.PostOwnerReactionNotification: {
let post = posts.get(notification.data.postId)

let post = await this.getSetMap<Post>('post', notification.data.postId, posts)
if (!post) {
const p = await Post.find(notification.data.postId)
if (!p) {
logger.error(`Attempted to notify. PostId ${notification.data.postId} not found.`)
break
}
posts.set(notification.data.postId, p)
post = p
logger.error(`Attempted to notify. PostId ${notification.data.postId} not found.`)
break
}

serializedNotification.data = {
Expand All @@ -91,6 +80,7 @@ export default class UserNotificationService {

break
}

case NotificationType.UserPostReportedNotification: {
serializedNotification.data = {
...serializedNotification.data,
Expand All @@ -102,6 +92,31 @@ export default class UserNotificationService {
}
break
}

case NotificationType.PostMentionNotification: {
let postAuthor = await this.getSetMap<UserResponse>(
'user',
notification.data.userId,
users
)
let post = await this.getSetMap<Post>('post', notification.data.postId, posts)
if (!postAuthor || !post) {
logger.error(`Attempted to notify. Resource not found.`)
break
}

serializedNotification.data = {
...serializedNotification.data,
title: serializedNotification.data.title.replace(
':authorFullName',
postAuthor.name ?? ''
),
message: serializedNotification.data.message.replace(':content', post.content),
user: postAuthor,
}
break
}

case NotificationType.PostReportingUserStatusNotification:
serializedNotification.data = {
...serializedNotification.data,
Expand All @@ -119,4 +134,30 @@ export default class UserNotificationService {

return data
}

private async getSetMap<T>(
type: 'user' | 'post',
id: UUID,
map: Map<UUID, T>
): Promise<T | null> {
let resource: T | null = map.get(id) || null

if (!resource) {
let item = null as T

if (type === 'user') {
item = (await this.userService.findOne(id)) as unknown as T
}

if (type === 'post') {
item = (await Post.find(id)) as unknown as T
}

map.set(id, item)
}

resource = map.get(id) || null

return resource
}
}
27 changes: 27 additions & 0 deletions app/services/user_service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PaginatedResponse } from './../interfaces/pagination'
import { AttachmentResponse } from '#interfaces/attachment'
import { UserResponse } from '#interfaces/user'
import { AttachmentModel, AttachmentType } from '#models/attachment'
Expand All @@ -13,6 +14,32 @@ export class UserService {
this.attachmentService = new AttachmentService()
}

async search(
searchTerm: string,
{ page, limit = 10 }: { page: number; limit?: number }
): Promise<PaginatedResponse<UserResponse>> {
const search = `%${searchTerm}%`

const result = await User.query()
.whereILike('username', search)
.orWhereILike('name', search)
.orderBy('updated_at', 'desc')
.paginate(page, limit)

const { meta } = result.toJSON()

const data: UserResponse[] = []
for (const user of result) {
const resource = await this.serialize(user)
data.push(resource)
}

return {
data,
meta,
}
}

async findOne(id: UUID): Promise<UserResponse | null> {
const user = await User.find(id)
if (!user) return null
Expand Down
7 changes: 7 additions & 0 deletions app/types/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Post from '#models/post'

declare module '@adonisjs/core/types' {
interface EventsList {
'post:mention': [string[], Post]
}
}
11 changes: 11 additions & 0 deletions app/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export function extractFirstLink(content: string): string | null {
return matches ? matches[0] : null
}

/**
* Replace last occurrence of a match from a string.
*/
export function replaceLast(text: string, searchValue: string, replaceValue: string) {
const lastOccurrenceIndex = text.lastIndexOf(searchValue)
return `${text.slice(0, lastOccurrenceIndex)}${replaceValue}${text.slice(
lastOccurrenceIndex + searchValue.length
)}`
}

/**
*
*/
Expand All @@ -36,4 +46,5 @@ export function sanitizePostContent(content: string): string {
export const REGEX = {
ALPHA_STRING: /^[A-z]+$/,
ALPHANUMERIC_STRING: /^[A-z0-9]+$/,
MENTIONS: /@[a-zA-Z0-9_-]+/g,
}
6 changes: 6 additions & 0 deletions config/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ declare module '@osenco/adonisjs-notifications/types' {
postId: UUID
postReactionType: PostReactionType
}

interface PostMentionNotificationData extends DatabaseChannelData {
type: NotificationType.PostMentionNotification
userId: UUID
postId: UUID
}
}
Loading

0 comments on commit 305a7ad

Please sign in to comment.