Skip to content

Commit

Permalink
[SOA-19] Add post reactions (#23)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): add post reaction model and migration

* feat 🎸 (be): add post reaction service, controller and routes

* styles 💅 : created post-reaction component

* feat 🎸 : progress on create/delete actions

* refactor ✨ : general improvements in UI

* test 🧪 : add reactions minimal browser test
  • Loading branch information
mariadriana-deemaze authored Nov 12, 2024
1 parent 664da6c commit 7076823
Show file tree
Hide file tree
Showing 20 changed files with 483 additions and 30 deletions.
6 changes: 4 additions & 2 deletions app/controllers/feed_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,22 @@ import type { HttpContext } from '@adonisjs/core/http'

@inject()
export default class FeedController {
constructor(private readonly postsService: PostsService) {}
constructor(private readonly postsService: PostsService) { }
async index(
ctx: HttpContext
): Promise<string | PageObject<{ posts: PaginatedResponse<PostResponse> }>> {
const currentUserId = ctx.auth.user?.id!;
const page = ctx.request.qs().page || 1

const posts = await Post.query()
.orderBy('updated_at', 'desc')
.preload('user')
.preload('reactions')
.paginate(page, 10)

const data: PostResponse[] = []
for (const post of posts) {
const resource = await this.postsService.serialize(post)
const resource = await this.postsService.serialize(currentUserId, post)
data.push(resource)
}

Expand Down
38 changes: 38 additions & 0 deletions app/controllers/post_reactions_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import PostReactionService from '#services/post_reaction_service';
import { errorsReducer } from '#utils/index';
import { postReactionValidator } from '#validators/post';
import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http'
import { errors } from '@vinejs/vine';

@inject()
export default class PostReactionsController {

constructor(
private readonly service: PostReactionService
) { }

async create(ctx: HttpContext) {
const postId = ctx.params.id;
const userId = ctx.auth.user?.id!;
const payload = ctx.request.body();
try {
const { reaction } = await postReactionValidator.validate(payload)
const [preExistant, resource] = await this.service.create(userId, postId, reaction);
return preExistant ? ctx.response.ok(resource) : ctx.response.created(resource);
} catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const reducedErrors = errorsReducer(error.messages)
return ctx.response.badRequest(reducedErrors);
}
return ctx.response.badRequest();
}
}

async destroy(ctx: HttpContext) {
const postId = ctx.params.id;
const userId = ctx.auth.user?.id!;
await this.service.destroy(userId, postId);
return ctx.response.noContent()
}
}
17 changes: 9 additions & 8 deletions app/controllers/posts_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,25 @@ import { PageObject } from '@adonisjs/inertia/types'

@inject()
export default class PostsController {
constructor(private service: service) {}
constructor(private service: service) { }

async show(ctx: HttpContext): Promise<
| string
| PageObject<
| {
post: PostResponse
}
| { post: null }
>
| {
post: PostResponse | null
}
>
> {
const currentUserId = ctx.auth.user?.id!;
const post = await this.service.findOne(ctx.params.id)
if (!post) {
return ctx.inertia.render('errors/not_found', {
post: null,
error: { title: 'Not found', message: 'We could not find the specified post.' },
})
}
const resource = await this.service.serialize(post)
const resource = await this.service.serialize(currentUserId, post)
return ctx.inertia.render('posts/show', {
post: resource,
})
Expand Down Expand Up @@ -64,6 +64,7 @@ export default class PostsController {
}

async update(ctx: HttpContext) {
const currentUserId = ctx.auth.user?.id!;
const post = await this.service.findOne(ctx.params.id)
if (!post) {
return ctx.inertia.render('errors/not_found', {
Expand Down Expand Up @@ -99,7 +100,7 @@ export default class PostsController {
}
return ctx.response.redirect().back()
}
const resource = await this.service.serialize(post)
const resource = await this.service.serialize(currentUserId, post)
return ctx.inertia.render('posts/show', { post: resource })
}

Expand Down
5 changes: 3 additions & 2 deletions app/controllers/users_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import type { HttpContext } from '@adonisjs/core/http'

@inject()
export default class UsersController {
constructor(public readonly service: PostsService) {}
constructor(public readonly service: PostsService) { }

async show(ctx: HttpContext) {
const currentUserId = ctx.auth.user?.id!;
const profileId = ctx.params.id
const page = ctx.request.qs().page || 1
const posts = await this.service.findMany(profileId, { page })
const posts = await this.service.findMany(currentUserId, profileId, { page })
const profile = await User.find(profileId)
return ctx.inertia.render('users/show', { posts, profile })
}
Expand Down
8 changes: 8 additions & 0 deletions app/enums/post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum PostReactionType {
LIKE = 'LIKE',
LOVE = 'LOVE',
THANKFUL = 'THANKFUL',
FUNNY = 'FUNNY',
ANGRY = 'ANGRY',
CONGRATULATIONS = 'CONGRATULATIONS',
}
6 changes: 6 additions & 0 deletions app/interfaces/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BaseEntity } from 'app/interfaces/base-entity'
import { UUID } from 'crypto'
import { UserResponse } from './user'
import { AttachmentResponse } from 'app/interfaces/attachment'
import { PostReactionType } from '#enums/post'

export interface LinkMetadataJSONResponse {
title: string
Expand All @@ -23,4 +24,9 @@ export interface PostResponse extends BaseEntity {
}
user: UserResponse
link: LinkResponse | null
reactions: {
reacted: PostReactionType | null,
reactionsCounts: Record<PostReactionType, number>
total:number;
}
}
13 changes: 9 additions & 4 deletions app/models/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
belongsTo,
column,
computed,
hasMany,
} from '@adonisjs/lucid/orm'
import User from '#models/user'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import type { UUID } from 'crypto'
import { extractFirstLink, sanitizePostContent } from '#utils/index'
import PostReaction from '#models/post_reaction'
import type { UUID } from 'crypto'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'

export default class Post extends BaseModel {
@column({ isPrimary: true })
Expand All @@ -19,11 +21,14 @@ export default class Post extends BaseModel {
@column()
declare content: string

@belongsTo(() => User)
declare user: BelongsTo<typeof User>

@column()
declare userId: UUID

@belongsTo(() => User)
declare user: BelongsTo<typeof User>
@hasMany(() => PostReaction)
declare reactions: HasMany<typeof PostReaction>

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
Expand Down
33 changes: 33 additions & 0 deletions app/models/post_reaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import Post from '#models/post'
import User from '#models/user'
import { PostReactionType } from '#enums/post'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import type { UUID } from 'crypto'

export default class PostReaction extends BaseModel {
@column({ isPrimary: true })
declare id: UUID

@belongsTo(() => User)
declare user: BelongsTo<typeof User>

@column()
declare userId: UUID

@belongsTo(() => Post)
declare post: BelongsTo<typeof Post>

@column()
declare postId: UUID

@column()
declare type: PostReactionType

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
49 changes: 49 additions & 0 deletions app/services/post_reaction_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { PostReactionType } from '#enums/post';
import PostReaction from '#models/post_reaction'
import type { UUID } from 'crypto'

export default class PostReactionService {
constructor() { }

async show(userId: UUID, postId: UUID): Promise<PostReaction | null> {
return PostReaction.findBy({
userId,
postId,
})
}

async create(userId: UUID, postId: UUID, type: PostReactionType): Promise<[boolean, PostReaction]> {
const existant = await this.show(
userId,
postId,
)

let resource: PostReaction;
if (existant) {
resource = await this.update(existant, type)
} else {
resource = await PostReaction.create({
userId,
postId,
type
})
}
return [!!existant, resource]

}

async update(reaction: PostReaction, type: PostReactionType): Promise<PostReaction> {
reaction.type = type;
await reaction.save()
return reaction
}

async destroy(userId: UUID, postId: UUID): Promise<void> {
const reaction = await this.show(
userId,
postId,
)
if (!reaction) return
await reaction.delete()
}
}
44 changes: 38 additions & 6 deletions app/services/posts_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import Post from '#models/post'
import { AttachmentModel } from '#models/attachment'
import AttachmentService from '#services/attachment_service'
import { createPostValidator, updatePostValidator } from '#validators/post'
import { ModelObject } from '@adonisjs/lucid/types/model'
import { PostResponse } from 'app/interfaces/post'
import LinkParserService from '#services/link_parser_service'
import { PaginatedResponse } from 'app/interfaces/pagination'
import { PostReactionType } from '#enums/post'
import PostReaction from '#models/post_reaction'
import { ModelObject } from '@adonisjs/lucid/types/model'
import type { HttpContext } from '@adonisjs/core/http'
import type { UUID } from 'crypto'

Expand Down Expand Up @@ -50,28 +52,33 @@ export default class PostsService {
* Finds a post and it's author, by record id.
*/
async findOne(id: UUID): Promise<Post | null> {
const result: Post[] | null = await Post.query().where('id', id).preload('user')
const result: Post[] | null = await Post.query()
.where('id', id)
.preload('user')
.preload('reactions')
return !!result ? result[0] : null
}

/**
* Returns a paginated collection of posts, matching the search criteria.
*/
async findMany(
currentUserId: UUID,
userId: UUID,
{ page, limit = 10 }: { page: number; limit?: number }
): Promise<PaginatedResponse<PostResponse>> {
const result = await Post.query()
.where('user_id', userId)
.orderBy('updated_at', 'desc')
.preload('user')
.preload('reactions')
.paginate(page, limit)

const { meta } = result.toJSON()

const data: PostResponse[] = []
for (const post of result) {
const resource = await this.serialize(post)
const resource = await this.serialize(currentUserId, post)
data.push(resource)
}

Expand Down Expand Up @@ -121,21 +128,46 @@ export default class PostsService {
}

/**
* Handles the process on serializing the post data, and aggregatin gits many attachments.
* Handles the process on serializing the post data, and aggregating it's many associations.
*/
async serialize(post: Post): Promise<PostResponse> {
const data: ModelObject = post.toJSON()
async serialize(currentUserId: UUID, post: Post): Promise<PostResponse> {
const data = post.toJSON() as ModelObject & { reactions: PostReaction[] }
const attachments = await this.attachmentService.findMany(AttachmentModel.POST, post.id)
const link = await this.linkService.show(post.link)

let accumulator: Record<PostReactionType, number> = {
[PostReactionType.LIKE]: 0,
[PostReactionType.THANKFUL]: 0,
[PostReactionType.FUNNY]: 0,
[PostReactionType.CONGRATULATIONS]: 0,
[PostReactionType.ANGRY]: 0,
[PostReactionType.LOVE]: 0,
}

const reactionsCounts: Record<PostReactionType, number> =
data?.reactions?.reduce((acc, next) => {
if (!next) return acc
acc[next.type] = acc[next.type] + 1
return acc
}, accumulator) || accumulator

const resource: PostResponse = {
id: data.id,
content: data.content,
user: data.user,
link,
attachments,
reactions: {
reacted:
data?.reactions?.find((reaction: PostReaction) => reaction.userId === currentUserId)
?.type || null,
reactionsCounts,
total: Object.values(reactionsCounts).reduce((prev, next) => prev + next, 0),
},
createdAt: data.createdAt,
updatedAt: data.updatedAt,
}

return resource
}
}
10 changes: 10 additions & 0 deletions app/validators/post.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PostReactionType } from '#enums/post'
import vine from '@vinejs/vine'

export const MIN_POST_CONTENT_SIZE = 8
Expand All @@ -20,3 +21,12 @@ export const updatePostValidator = vine.compile(
content: vine.string().minLength(MIN_POST_CONTENT_SIZE).maxLength(MAX_POST_CONTENT_SIZE),
})
)

/**
* Validates the post-react create/update action payload
*/
export const postReactionValidator = vine.compile(
vine.object({
reaction: vine.enum(PostReactionType),
})
)
Loading

0 comments on commit 7076823

Please sign in to comment.