Skip to content

Commit

Permalink
[SOA-5] Review reported content (#34)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): add routing for post moderation index

* styles 💅 : add post moderation index page

* styles 💅 (wip): progress on update status form

* feat 🎸 (be): controlled serialisation and route for updating status

* fix ✅ (be): uniqueness constraint correction

* styles 💅 : adaptions for the admin update status interface

* feat 🎸 (be): update post status and add visibility scope to post

* fix ✅ (be): scoped field name

* perf ⚡️ : improve feed list performance with partial prop reload

* styles 💅 : style adjustments on modal card-preview

* styles 💅 : nit and decompose admin header

* fix ✅ (be): correction to middleware and missing cascade

* test 🧪 : add minimal admin_post_report tests

* fix ✅ (be): add new migration for the fields update
  • Loading branch information
mariadriana-deemaze authored Nov 19, 2024
1 parent dfe2fd7 commit b185878
Show file tree
Hide file tree
Showing 30 changed files with 1,493 additions and 51 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
S3_BUCKET=
VITE_APP_NAME=SocialAdonis
59 changes: 59 additions & 0 deletions app/controllers/admin_post_reports_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { PageObject } from '@adonisjs/inertia/types'
import { inject } from '@adonisjs/core'
import AdminPostReportService from '#services/admin_post_report_service'
import { PaginatedResponse } from '#interfaces/pagination'
import { PostReportResponse } from '#interfaces/post'
import { adminUpdatePostReportValidator } from '#validators/post_report'
import PostReport from '#models/post_report'
import { errorsReducer } from '#utils/index'
import { errors } from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'

@inject()
export default class AdminPostReportsController {
constructor(private readonly service: AdminPostReportService) {}

async index(ctx: HttpContext): Promise<
| string
| PageObject<{
reports: PaginatedResponse<PostReportResponse>
}>
> {
const currentUserId = ctx.auth.user?.id!
const page = ctx.request.qs().page || 1

const filters: Record<'reason' | 'status', string[] | null> = {
reason: ctx.request.qs().reason ? [ctx.request.qs().reason].flat() : null,
status: ctx.request.qs().status ? [ctx.request.qs().status].flat() : null,
}

const reports = await this.service.index(currentUserId, filters, page)

return ctx.inertia.render('admin/post_reports/index', {
reports,
})
}

async update(ctx: HttpContext) {
const reportId = ctx.request.params().id
const report = await PostReport.findOrFail(reportId)

if (await ctx.bouncer.with('PostReportPolicy').denies('edit', report)) {
return ctx.response.forbidden('Only admin is able to take action on report status.')
}

try {
const payload = ctx.request.body()
const data = await adminUpdatePostReportValidator.validate(payload)
report.status = data.status
await report.save()
return this.index(ctx)
} catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const reducedErrors = errorsReducer(error.messages)
return ctx.response.badRequest(reducedErrors)
}
return ctx.response.badRequest()
}
}
}
1 change: 1 addition & 0 deletions app/controllers/feed_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default class FeedController {

const posts = await Post.query()
.orderBy('updated_at', 'desc')
.withScopes((scope) => scope.visible())
.preload('user')
.preload('reactions')
.paginate(page, 10)
Expand Down
13 changes: 12 additions & 1 deletion app/interfaces/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BaseEntity } from '#interfaces/base_entity'
import { UUID } from 'node:crypto'
import { UserResponse } from './user'
import { AttachmentResponse } from 'app/interfaces/attachment'
import { PostReactionType } from '#enums/post'
import { PostReactionType, PostReportReason, PostReportStatus } from '#enums/post'

export interface LinkMetadataJSONResponse {
title: string
Expand Down Expand Up @@ -30,3 +30,14 @@ export interface PostResponse extends BaseEntity {
total: number
}
}

export interface PostReportResponse extends BaseEntity {
id: UUID
reason: PostReportReason
status: PostReportStatus
description: string
userId: UUID
postId: UUID
post: PostResponse & { reportCount: number }
user: UserResponse
}
4 changes: 2 additions & 2 deletions app/middleware/auth_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default class AuthMiddleware {
redirectTo = '/auth/sign-in'

/**
* The URL to redirect to, when authentication fails
*
* The URL to redirect the admin to, when authentication fails
*/
adminRedirectTo = '/admin/auth/sign-in'

Expand All @@ -26,7 +27,6 @@ export default class AuthMiddleware {
) {
let guard: keyof Authenticators = 'web'
if (ctx.route?.pattern.includes('admin')) guard = 'admin-web'

try {
await ctx.auth.authenticateUsing([guard])
return next()
Expand Down
5 changes: 5 additions & 0 deletions app/models/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
column,
computed,
hasMany,
scope,
} from '@adonisjs/lucid/orm'
import User from '#models/user'
import { extractFirstLink, sanitizePostContent } from '#utils/index'
Expand Down Expand Up @@ -45,6 +46,10 @@ export default class Post extends BaseModel {
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null

static visible = scope((query) => {
query.where('status', PostStatus.PUBLISHED)
})

@computed()
get link(): string | null {
return extractFirstLink(this.content)
Expand Down
72 changes: 72 additions & 0 deletions app/services/admin_post_report_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { PaginatedResponse } from '#interfaces/pagination'
import { PostReportResponse } from '#interfaces/post'
import PostReport from '#models/post_report'
import PostsService from '#services/posts_service'
import { UserService } from '#services/user_service'
import { inject } from '@adonisjs/core'
import { UUID } from 'node:crypto'

@inject()
export default class AdminPostReportService {
constructor(
private readonly userService: UserService,
private readonly postService: PostsService
) {}

async index(
currentUserId: UUID,
filters: Record<'reason' | 'status', string[] | null>,
currentPage: number
): Promise<PaginatedResponse<PostReportResponse>> {
let query = PostReport.query()
.orderBy('updated_at', 'desc')
.preload('post', (post) => {
post.preload('user')
post.withCount('reports', (q) => q.as('reportsCount'))
})
.preload('user')

if (filters.reason) {
query.whereIn('reason', filters.reason)
}

if (filters.status) {
query.whereIn('status', filters.status)
}

const result = await query.paginate(currentPage, 10)

const { meta } = result.toJSON()

const serialized = []
for (const record of result) {
const resource = await this.serialize(currentUserId, record)
serialized.push(resource)
}

return {
data: serialized,
meta,
}
}

private async serialize(currentUserId: UUID, report: PostReport): Promise<PostReportResponse> {
const user = await this.userService.serialize(report.user)
const post = await this.postService.serialize(currentUserId, report.post)
const data = report.toJSON()

const resource: PostReportResponse = {
id: report.id,
postId: data.postId,
userId: data.userId,
reason: data.reason,
status: data.status,
description: data.description,
post: { ...post, reportCount: report.post.$extras.reportsCount },
user,
updatedAt: data.updatedAt,
createdAt: data.createdAt,
}
return resource
}
}
2 changes: 2 additions & 0 deletions app/services/posts_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default class PostsService {
*/
async findOne(id: UUID): Promise<Post | null> {
const result: Post[] | null = await Post.query()
.withScopes((scope) => scope.visible())
.where('id', id)
.preload('user')
.preload('reactions')
Expand All @@ -72,6 +73,7 @@ export default class PostsService {
): Promise<PaginatedResponse<PostResponse>> {
const result = await Post.query()
.where('user_id', userId)
.withScopes((scope) => scope.visible())
.orderBy('updated_at', 'desc')
.preload('user')
.preload('reactions')
Expand Down
11 changes: 10 additions & 1 deletion app/validators/post_report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PostReportReason } from '#enums/post'
import { PostReportReason, PostReportStatus } from '#enums/post'
import vine from '@vinejs/vine'

/**
Expand All @@ -10,3 +10,12 @@ export const postReportValidator = vine.compile(
description: vine.string(),
})
)

/**
* Validates the administrator post-report update action payload
*/
export const adminUpdatePostReportValidator = vine.compile(
vine.object({
status: vine.enum([PostReportStatus.ACCEPTED, PostReportStatus.REJECTED]),
})
)
1 change: 1 addition & 0 deletions config/inertia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const inertiaConfig = defineConfig({
return null
}
},
queryParams: (ctx) => ctx.request.qs(),
errors: (ctx) => ctx.session?.flashMessages.get('errors'),
},

Expand Down
4 changes: 3 additions & 1 deletion database/factories/user_factory.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import factory from '@adonisjs/lucid/factories'
import User from '#models/user'
import User, { AccountRole } from '#models/user'
import { PostFactory } from '#database/factories/post_factory'

export const UserFactory = factory
.define(User, async ({ faker }) => {
return {
role: AccountRole.USER,
name: faker.person.firstName(),
surname: faker.person.lastName(),
email: faker.internet.email(),
password: faker.internet.password(),
}
})
.state('admin', (user) => (user.role = AccountRole.ADMIN))
.relation('posts', () => PostFactory)
.build()
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'post_reports'

async up() {
this.schema.alterTable(this.tableName, (table) => {
table.dropForeign('post_id');
table.uuid('post_id').references('posts.id').notNullable().onDelete('CASCADE').alter()
table.unique(['user_id', 'post_id'])
})
}

async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropForeign('post_id');
table.uuid('post_id').references('posts.id').notNullable().alter()
table.dropUnique(['user_id', 'post_id'])
})
}
}
2 changes: 1 addition & 1 deletion inertia/app/admin_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<>
<link rel="icon" type="image/svg+xml" href={favicon} />
<NavBar user={user} />
<div className="container flex justify-start pt-20">{children}</div>
<div className="flex flex-col justify-start pt-20">{children}</div>
<Toaster />
</>
)
Expand Down
4 changes: 2 additions & 2 deletions inertia/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { resolvePageComponent } from '@adonisjs/inertia/helpers'
import Layout from '@/app/layout'
import AdminLayout from '@/app/admin_layout'

const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS'
const appName = import.meta.env.VITE_APP_NAME

createInertiaApp({
progress: { color: '#5468FF' },
title: (title: string) => `${title} - ${appName}`,
title: (title: string) => `${appName} ${!!title && '|'} ${title}`,
resolve: async (name: string) => {
const page: any = await resolvePageComponent(
`../pages/${name}.tsx`,
Expand Down
7 changes: 5 additions & 2 deletions inertia/components/admin/generic/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import AdonisLogo from '@/components/svg/logo'
import { UserResponse } from '#interfaces/user'
import { cn } from '@/lib/utils'
import { UserResponse } from '#interfaces/user'
import { PostReportStatus } from '#enums/post'

export default function NavBar({ user }: { user: UserResponse | null }) {
const LINKS: Record<'title' | 'link', string>[] = [
Expand All @@ -23,7 +24,8 @@ export default function NavBar({ user }: { user: UserResponse | null }) {
},
{
title: 'Reports',
link: '/admin/index',
// FIX-ME: izzy.
link: `/admin/posts/reports?status[]=${PostReportStatus.PENDING}`,
},
]

Expand All @@ -37,6 +39,7 @@ export default function NavBar({ user }: { user: UserResponse | null }) {
<Link
key={`link-${index}`}
href={link}
except={['user']}
className="text-white text-sm font-medium transition-colors hover:text-primary"
>
{title}
Expand Down
1 change: 1 addition & 0 deletions inertia/components/posts/feed_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function FeedList({
`${url}${meta.nextPageUrl}`,
{},
{
only: ['posts'],
preserveState: true,
preserveScroll: true,
onSuccess: () => {
Expand Down
Loading

0 comments on commit b185878

Please sign in to comment.