Skip to content

Commit

Permalink
[SOA-10] Enable post with attachments (#20)
Browse files Browse the repository at this point in the history
* chore πŸ—οΈ (deps): add flydrive

* styles πŸ’… : created file-upload-preview component

* feat 🎸 (wip): put/get files from configured disk

* feat 🎸 (be): add attachment model

* feat 🎸 (be): add response interfaces

* feat 🎸 (be): adapted controllers and services to handle attachments

* fix βœ… (be): correction to migration null fields

* styles πŸ’… : prepare file uploads and display

* feat 🎸 (be): introduce other assets for upload

* feat 🎸 (be): introduce other assets for upload - correction

* styles πŸ’… : improve display for image gallery and user actions placement

* styles πŸ’… : improvements on post create/update

* fix βœ… (be): regenerate migration, and correct timestamp tmz

* refactor ✨ : minor nits

* test πŸ§ͺ (fix): correction to minimal browser tests
  • Loading branch information
mariadriana-deemaze authored Nov 11, 2024
1 parent 72e213f commit bf45cff
Show file tree
Hide file tree
Showing 29 changed files with 2,024 additions and 296 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ DB_PASSWORD=root
DB_DATABASE=social-adonis
SENTRY_DSN=<your_dsn_url>
LINK_PREVIEW_API=key
DRIVE_DISK=s3
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
S3_BUCKET=
3 changes: 2 additions & 1 deletion adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export default defineConfig({
() => import('@adonisjs/auth/auth_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('@rlanz/sentry/provider'),
() => import('@adonisjs/bouncer/bouncer_provider')
() => import('@adonisjs/bouncer/bouncer_provider'),
() => import('@adonisjs/drive/drive_provider')
],

/*
Expand Down
24 changes: 12 additions & 12 deletions app/controllers/feed_controller.ts
Original file line number Diff line number Diff line change
@@ -1,38 +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 { PostResponse } from 'app/interfaces/post'
import { PageObject } from '@adonisjs/inertia/types'
import { PaginatedResponse } from 'app/interfaces/pagination'
import type { HttpContext } from '@adonisjs/core/http'

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

async index(
ctx: HttpContext
): Promise<string | PageObject<{ posts: PaginatedResponse<PostResponse> }>> {
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)

const data: PostResponse[] = []

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

const { meta } = posts.toJSON()
const { meta } = posts.toJSON();

return ctx.inertia.render('feed', {
posts: {
data,
meta,
},
meta
}
})
}
}
55 changes: 38 additions & 17 deletions app/controllers/posts_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,62 @@ import service from '#services/posts_service'
import policy from '#policies/posts_policy'
import Post from '#models/post'
import { errorsReducer } from '#utils/index'
import { PostResponse } from 'app/interfaces/post'
import { PageObject } from '@adonisjs/inertia/types'

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

async show(ctx: HttpContext) {
async show(ctx: HttpContext): Promise<string | PageObject<{
post: PostResponse;
} | { post: null }>> {
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(post);
return ctx.inertia.render('posts/show', {
post: resource,
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({
const post = await this.service.create({
userId: ctx.auth.user?.id!,
payload,
})
});

try {
await this.service.storeAttachments(ctx, post.id);
} catch (error) {
await post.delete()
ctx.session.flash('errors', {
images: "Invalid file."
})
}

} catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const reducedErrors = errorsReducer(error.messages)
ctx.session.flash('errors', reducedErrors)
}

}

return ctx.response.redirect().back()
}

async update(ctx: HttpContext) {
const post = await this.service.findOne(ctx.params.id)

if (!post) {
return ctx.inertia.render('errors/not_found', {
post: null,
Expand All @@ -67,25 +79,34 @@ export default class PostsController {
post,
payload,
})
await this.service.deleteAttachments(post.id);
try {
await this.service.storeAttachments(ctx, post.id);
} catch (error) {
await post.delete()
ctx.session.flash('errors', {
images: "Invalid file."
})
}

} catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const reducedErrors = errorsReducer(error.messages)
ctx.session.flash('errors', reducedErrors)
}
return ctx.response.redirect().back()
}

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

async destroy({ params, bouncer, response }: HttpContext) {
const post = await Post.findOrFail(params.id)

if (await bouncer.with(policy).denies('delete', post)) {
return response.forbidden('Not the author of this post.')
async destroy(ctx: HttpContext) {
const post = await Post.findOrFail(ctx.params.id)
if (await ctx.bouncer.with(policy).denies('delete', post)) {
return ctx.response.forbidden('Not the author of this post.')
}

await this.service.deleteAttachments(post.id);
await post.delete()
return ctx.response.redirect().back()
}
}
14 changes: 14 additions & 0 deletions app/interfaces/attachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { UUID } from "crypto"

export interface AttachmentMetadataJSON {
filename: string;
size: number;
mimetype: string;
extension: string;
}

export interface AttachmentResponse {
id: UUID;
link: string;
metadata: AttachmentMetadataJSON;
}
8 changes: 4 additions & 4 deletions app/interfaces/base-entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface BaseEntity {
updatedAt: string
createdAt: string
}
export interface BaseEntity {
updatedAt: string
createdAt: string
}
32 changes: 16 additions & 16 deletions app/interfaces/pagination.ts
Original file line number Diff line number Diff line change
@@ -1,16 +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
}
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
}
4 changes: 4 additions & 0 deletions app/interfaces/post.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseEntity } from 'app/interfaces/base-entity'
import { UUID } from 'crypto'
import { UserResponse } from './user'
import { AttachmentResponse } from 'app/interfaces/attachment'

export interface LinkMetadataJSONResponse {
title: string
Expand All @@ -17,6 +18,9 @@ export interface LinkResponse {
export interface PostResponse extends BaseEntity {
id: UUID
content: string
attachments: {
images: AttachmentResponse[]
}
user: UserResponse
link: LinkResponse | null
}
13 changes: 11 additions & 2 deletions app/interfaces/user.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import { ModelObject } from '@adonisjs/lucid/types/model'
import { AccountRole } from '#models/user'
import { BaseEntity } from 'app/interfaces/base-entity'
import { UUID } from 'crypto'

export interface UserResponse extends ModelObject {} // TODO: Complete me.
export interface UserResponse extends BaseEntity {
id: UUID,
role: AccountRole,
name: string
surname: string
username: string
email: string,
}
69 changes: 69 additions & 0 deletions app/models/attachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import type { UUID } from 'crypto';
import { AttachmentMetadataJSON } from 'app/interfaces/attachment';

export enum AttachmentProvider {
S3 = "S3",
}

export enum AttachmentType {
IMAGE = "Image",
AUDIO = "Audio",
DOCUMENT = "Document",
VIDEO = "Video",
}

export enum AttachmentModel {
USER = "User",
POST = "Post",
}

export class MetadataJSON {
// TODO: Could benefit in adding class-validator?
declare filename: string;
declare size: number;
declare mimetype: string;
declare extension: string;

constructor(data: AttachmentMetadataJSON) {

if ((!data.filename && typeof data.filename !== 'string') ||
(!data.size && typeof data.size !== 'number') ||
(!data.mimetype && typeof data.mimetype !== 'string') ||
(!data.extension && typeof data.extension !== 'string')) {
throw new Error("Invalid file.")
}

Object.assign(this, data)
}
}

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

@column()
declare type: AttachmentType

@column()
declare model: AttachmentModel

@column()
declare model_id: UUID

@column()
declare external_key: string

@column()
provider: AttachmentProvider = AttachmentProvider.S3

@column({})
declare metadata: MetadataJSON;

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

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
5 changes: 0 additions & 5 deletions app/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,6 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column()
role: AccountRole = AccountRole.USER

@computed()
get isAdmin() {
return this.role === AccountRole.ADMIN
}

@column({ serializeAs: null })
declare password: string

Expand Down
Loading

0 comments on commit bf45cff

Please sign in to comment.