Skip to content

Commit

Permalink
[SOA-15] Enable parsing of link previews (#16)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): add link metadata model, migration and computed post property

* feat 🎸 (be): add response interfaces

* feat 🎸 (be): adapt controller responses

* styles 💅 : created link-preview component

* fix ✅ (be): force update timestamps and fields without timezone

* styles 💅 : improvements on link-preview component

* fix ✅ (be): serialize post update response

* fix ✅ (build): types nits
  • Loading branch information
mariadriana-deemaze authored Nov 9, 2024
1 parent 3295ab8 commit 550e3fb
Show file tree
Hide file tree
Showing 16 changed files with 398 additions and 69 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ DB_USER=postgres
DB_PASSWORD=root
DB_DATABASE=social-adonis
SENTRY_DSN=<your_dsn_url>
LINK_PREVIEW_API=key
28 changes: 25 additions & 3 deletions app/controllers/feed_controller.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import Post from '#models/post'
import { PostResponse } from 'app/interfaces/post'
import PostsService from '#services/posts_service'
import { PageObject } from '@adonisjs/inertia/types'
import { PaginatedResponse } from 'app/interfaces/pagination'

@inject()
export default class FeedController {
constructor() {}
async index(ctx: HttpContext) {
constructor(private readonly postsService: PostsService) {}

async index(
ctx: HttpContext
): Promise<string | PageObject<{ posts: PaginatedResponse<PostResponse> }>> {
const page = ctx.request.qs().page || 1
const posts = await Post.query()
.orderBy('updated_at', 'desc')
.preload('user')
.paginate(page, 10)
return ctx.inertia.render('feed', { posts })

const data: PostResponse[] = []

for (const post of posts) {
const resource = await this.postsService.serialize(post)
data.push(resource)
}

const { meta } = posts.toJSON()

return ctx.inertia.render('feed', {
posts: {
data,
meta,
},
})
}
}
30 changes: 19 additions & 11 deletions app/controllers/posts_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,34 @@ import { errorsReducer } from '#utils/index'

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

async show(ctx: HttpContext) {
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.' } });
return ctx.inertia.render('errors/not_found', {
post: null,
error: { title: 'Not found', message: 'We could not find the specified post.' },
})
}

return ctx.inertia.render('posts/show', { post })
const resource = await this.service.serialize(post)
return ctx.inertia.render('posts/show', {
post: resource,
})
}

async create(ctx: HttpContext) {
if (await ctx.bouncer.with(policy).denies('create')) {
return ctx.response.forbidden('Cannot create a post.')
}

const payload = ctx.request.body();
const payload = ctx.request.body()

try {
await this.service.create({
userId: ctx.auth.user?.id!,
payload,
});
})
} catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const reducedErrors = errorsReducer(error.messages)
Expand All @@ -46,14 +50,17 @@ export default class PostsController {
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.' } });
return ctx.inertia.render('errors/not_found', {
post: null,
error: { title: 'Not found', message: 'We could not find the specified post.' },
})
}

if (await ctx.bouncer.with(policy).denies('edit', post)) {
return ctx.response.forbidden('Not the author of this post.')
}

const payload = ctx.request.body();
const payload = ctx.request.body()

try {
await this.service.update({
Expand All @@ -68,7 +75,8 @@ export default class PostsController {
return ctx.response.redirect().back()
}

return ctx.inertia.render('posts/show', { post })
const resource = await this.service.serialize(post)
return ctx.inertia.render('posts/show', { post: resource })
}

async destroy({ params, bouncer, response }: HttpContext) {
Expand All @@ -78,6 +86,6 @@ export default class PostsController {
return response.forbidden('Not the author of this post.')
}

await post.delete();
await post.delete()
}
}
4 changes: 4 additions & 0 deletions app/interfaces/base-entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface BaseEntity {
updatedAt: string
createdAt: string
}
16 changes: 16 additions & 0 deletions app/interfaces/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface MetaResponse {
total: number
perPage: number
currentPage: number
lastPage: number
firstPage: number
firstPageUrl: string
lastPageUrl: string
nextPageUrl: string | null
previousPageUrl: string | null
}

export interface PaginatedResponse<T> {
data: T[]
meta: MetaResponse
}
22 changes: 22 additions & 0 deletions app/interfaces/post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { BaseEntity } from 'app/interfaces/base-entity'
import { UUID } from 'crypto'
import { UserResponse } from './user'

export interface LinkMetadataJSONResponse {
title: string
description: string
thumbnail: string
}

export interface LinkResponse {
id: UUID
link: string
metadata: LinkMetadataJSONResponse
}

export interface PostResponse extends BaseEntity {
id: UUID
content: string
user: UserResponse
link: LinkResponse | null
}
3 changes: 3 additions & 0 deletions app/interfaces/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ModelObject } from '@adonisjs/lucid/types/model'

export interface UserResponse extends ModelObject {} // TODO: Complete me.
40 changes: 40 additions & 0 deletions app/models/link-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { LinkMetadataJSONResponse } from 'app/interfaces/post'
import type { UUID } from 'crypto'

export class LinkMetadataJSON {
// TODO: Could benefit in adding class-validator?
declare thumbnail: string
declare title: string
declare description: string

constructor(data: LinkMetadataJSONResponse) {
if (
typeof data.thumbnail !== 'string' ||
typeof data.title !== 'string' ||
typeof data.description !== 'string'
) {
throw new Error('Invalid link json metadata.')
}

Object.assign(this, data)
}
}

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

@column()
declare link: string

@column()
declare metadata: LinkMetadataJSON

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

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
10 changes: 8 additions & 2 deletions app/models/post.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import { BaseModel, belongsTo, column, computed } from '@adonisjs/lucid/orm'
import User from '#models/user'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import type { UUID } from 'crypto'
import User from '#models/user'
import { extractFirstLink } from '#utils/index'

export default class Post extends BaseModel {
@column({ isPrimary: true })
Expand All @@ -22,4 +23,9 @@ export default class Post extends BaseModel {

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null

@computed()
get link(): string | null {
return extractFirstLink(this.content)
}
}
97 changes: 97 additions & 0 deletions app/services/link_parser_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import LinkMetadata from '#models/link-metadata'
import { LinkResponse } from 'app/interfaces/post'
import { differenceInHours } from 'date-fns'
import env from '#start/env'

export default class LinkParserService {
private API_Link: string
private API_Key: string

constructor() {
this.API_Link = 'https://api.linkpreview.net'
this.API_Key = env.get('LINK_PREVIEW_API')
}

async get(link: string): Promise<{
title: string
description: string
image: string
} | null> {
const data = fetch(this.API_Link, {
method: 'POST',
headers: {
'X-Linkpreview-Api-Key': this.API_Key,
},
mode: 'cors',
body: JSON.stringify({ q: link }),
})
.then((res) => {
if (res.status != 200) {
console.log(res.status)
throw new Error('something went wrong')
}
return res.json()
})
.then((response: { title: string; description: string; image: string }) => {
return {
title: response.title,
description: response.description,
image: response.image,
}
})
.catch((error) => {
console.log(error)
return null
})

return data
}

async store(link: string): Promise<LinkResponse | null> {
const metadata = await this.get(link)
if (!metadata) return null
const record = await LinkMetadata.create({
link,
metadata: {
title: metadata.title,
description: metadata.description,
thumbnail: metadata.image,
},
})
return this.serialize(record)
}

async update(record: LinkMetadata): Promise<LinkResponse | null> {
const data = await this.get(record.link)
record.metadata.thumbnail = data?.image || record.metadata.thumbnail
record.metadata.title = data?.title || record.metadata.title
record.metadata.description = data?.description || record.metadata.description
record.enableForceUpdate()
await record.save()
return this.serialize(record)
}

async show(link: string | null): Promise<LinkResponse | null> {
if (!link) return null
const record = await LinkMetadata.findBy('link', link)
let resource: LinkResponse | null
if (record) {
if (differenceInHours(new Date(), record?.updatedAt.toJSDate()) >= 12) {
resource = await this.update(record)
} else {
resource = this.serialize(record)
}
} else {
resource = await this.store(link)
}
return resource
}

private serialize(record: LinkMetadata): LinkResponse {
return {
id: record.id,
link: record.link,
metadata: record.metadata,
}
}
}
Loading

0 comments on commit 550e3fb

Please sign in to comment.