Skip to content

Commit

Permalink
[SOA-21] User profile (#26)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): prepare service and controllers for the users settings

* styles 💅 : add setting page and apply layout tweaks

* feat 🎸 (wip): progress

* feat 🎸 : upload screen functionalities, and update user response shared props

* fix ✅ (fe): correct cast

* fix ✅ : improve readibility

* refactor ✨ : improve routing middlewares

* refactor ✨ : improve auth validator and other nits

* styles 💅 : improvements on user profile display

* fix ✅ (be): delete associations on post delete

* test 🧪 : add minimal browser tests for user profile settings

* fix ✅ : type correction

* fix ✅ (be): review upload external key handling logic
  • Loading branch information
mariadriana-deemaze authored Nov 13, 2024
1 parent 7076823 commit 1d52657
Show file tree
Hide file tree
Showing 29 changed files with 835 additions and 203 deletions.
17 changes: 15 additions & 2 deletions app/controllers/feed_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ 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 { UserService } from '#services/user_service'
import type { HttpContext } from '@adonisjs/core/http'

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

const posts = await Post.query()
Expand All @@ -36,4 +40,13 @@ export default class FeedController {
},
})
}

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.postsService.findMany(currentUserId, profileId, { page })
const profile = await this.userService.findOne(profileId)
return ctx.inertia.render('users/show', { posts, profile })
}
}
12 changes: 5 additions & 7 deletions app/controllers/posts_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,15 @@ 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<
| {
| PageObject<{
post: PostResponse | null
}
>
}>
> {
const currentUserId = ctx.auth.user?.id!;
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 @@ -64,7 +62,7 @@ export default class PostsController {
}

async update(ctx: HttpContext) {
const currentUserId = ctx.auth.user?.id!;
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
63 changes: 51 additions & 12 deletions app/controllers/users_controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,57 @@
import User from '#models/user'
import PostsService from '#services/posts_service'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { HttpContext } from '@adonisjs/core/http'
import { updateUserValidator } from '#validators/user'
import { errors } from '@vinejs/vine'
import { errorsReducer } from '#utils/index'
import { UserService } from '#services/user_service'
import { UserResponse } from '#interfaces/user'
import { PageObject } from '@adonisjs/inertia/types'

@inject()
export default class UsersController {
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(currentUserId, profileId, { page })
const profile = await User.find(profileId)
return ctx.inertia.render('users/show', { posts, profile })
constructor(private readonly service: UserService) {}

async show(ctx: HttpContext): Promise<
| string
| PageObject<{
user: UserResponse
}>
> {
const user = await this.service.serialize(ctx.auth.user!)
return ctx.inertia.render('users/settings', { user })
}

async update(ctx: HttpContext) {
const user = ctx.auth.user!

try {
const data = await ctx.request.validateUsing(updateUserValidator, {
meta: {
userId: user.id,
},
})

await this.service.update(user, {
name: data.name,
surname: data.surname,
username: data.username,
email: data.email,
})

await this.service.storeAttachments(ctx)

return ctx.inertia.render('users/settings')
} 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 delete() {
// TODO: Implement.
}
}
2 changes: 2 additions & 0 deletions app/interfaces/attachment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AttachmentType } from '#models/attachment'
import { UUID } from 'crypto'

export interface AttachmentMetadataJSON {
Expand All @@ -10,5 +11,6 @@ export interface AttachmentMetadataJSON {
export interface AttachmentResponse {
id: UUID
link: string
type: AttachmentType
metadata: AttachmentMetadataJSON
}
9 changes: 7 additions & 2 deletions app/interfaces/user.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { AttachmentResponse } from '#interfaces/attachment'
import { AccountRole } from '#models/user'
import { BaseEntity } from 'app/interfaces/base-entity'
import { UUID } from 'crypto'

export interface UserResponse extends BaseEntity {
id: UUID
role: AccountRole
name: string
surname: string
name: string | null
surname: string | null
username: string
email: string
attachments: {
cover: AttachmentResponse | null
avatar: AttachmentResponse | null
}
}
17 changes: 7 additions & 10 deletions app/middleware/auth_middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'

/**
* Auth middleware is used authenticate HTTP requests and deny
Expand All @@ -12,14 +11,12 @@ export default class AuthMiddleware {
*/
redirectTo = '/auth/sign-in'

async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
return next()
async handle(ctx: HttpContext, next: NextFn) {
try {
await ctx.auth.authenticate()
return next()
} catch (error) {
return ctx.response.redirect().toPath(this.redirectTo)
}
}
}
24 changes: 9 additions & 15 deletions app/middleware/guest_middleware.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'

/**
* Guest middleware is used to deny access to routes that should
* Guest middleware is used to deny access to routes that can
* be accessed by unauthenticated users.
*
* For example, the login page should not be accessible if the user
* is already logged-in
*/
export default class GuestMiddleware {
/**
* The URL to redirect to when user is logged-in
*/
redirectTo = '/feed'
async handle(ctx: HttpContext, next: NextFn) {
let user = null

async handle(
ctx: HttpContext,
next: NextFn,
options: { guards?: (keyof Authenticators)[] } = {}
) {
for (let guard of options.guards || [ctx.auth.defaultGuard]) {
if (await ctx.auth.use(guard).check()) {
return ctx.response.redirect(this.redirectTo, true)
}
try {
user = await ctx.auth.authenticate()
} catch (error) {}

if (!!user && ctx.route?.pattern.includes('auth')) {
return ctx.response.redirect().back()
}

return next()
Expand Down
2 changes: 2 additions & 0 deletions app/models/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export enum AttachmentType {
AUDIO = 'Audio',
DOCUMENT = 'Document',
VIDEO = 'Video',
AVATAR = 'Avatar',
COVER = 'Cover',
}

export enum AttachmentModel {
Expand Down
6 changes: 6 additions & 0 deletions app/models/post.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DateTime } from 'luxon'
import {
BaseModel,
beforeDelete,
beforeSave,
beforeUpdate,
belongsTo,
Expand Down Expand Up @@ -46,4 +47,9 @@ export default class Post extends BaseModel {
static sanitizeContent(post: Post) {
post.content = sanitizePostContent(post.content)
}

@beforeDelete()
public static async deleteAssociations(post: Post) {
await post.related('reactions').query().delete()
}
}
47 changes: 47 additions & 0 deletions app/services/attachment_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,26 @@ export default class AttachmentService {
resources.images.push({
id: attachment.id,
link,
type: attachment.type,
metadata: attachment.metadata,
})
}

return resources
}

/**
* Polymorphic find of many attachments to a specified model.
* Returns the records.
*/
async findManyRaw(model: AttachmentModel, model_id: string) {
const attachments = await Attachment.findManyBy({
model,
model_id,
})
return attachments
}

/**
* Polymorphic find of many attachments to a specified model.
*/
Expand Down Expand Up @@ -86,6 +99,40 @@ export default class AttachmentService {
// NOTE: Videos could be handed over here to a different provider.
}

async storeOne(model: AttachmentModel, modelId: UUID, type: AttachmentType, file: MultipartFile) {
const extension = file.extname || file.subtype || file.headers['content-type']

const attachment = new Attachment()
attachment.model = model
attachment.type = type
attachment.model_id = modelId
attachment.metadata = new MetadataJSON({
filename: file.clientName,
size: file.size,
mimetype: file.headers['content-type'],
extension,
})

let key = this.generateS3Key(type, extension)
await file.moveToDisk(key)
attachment.external_key = key
await attachment.save()
}

async getPresignedLink(externalKey: string) {
return this.disk.getSignedUrl(externalKey)
}

/**
* Updates file in given key.
*/
async update(key: string, file: MultipartFile) {
await file.moveToDisk(key)
}

/**
* Generates a key in disk
*/
private generateS3Key(type: AttachmentType, extension: string) {
return `uploads/${type}/${cuid()}.${extension}`
}
Expand Down
7 changes: 0 additions & 7 deletions app/services/auth_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ export default class AuthService {

try {
const payload = await request.validateUsing(createAuthValidator)
const existant = await User.query().where('email', payload.email).first()
if (existant) {
session.flash('errors', {
email: 'An user with the provided email already exists.',
})
return response.redirect().back()
}

const user = new User()
Object.assign(user, payload)
Expand Down
Loading

0 comments on commit 1d52657

Please sign in to comment.