diff --git a/bun.lock b/bun.lock index 6c19c50..0820c34 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "fileflowbe", diff --git a/src/controllers/favorite.controller.ts b/src/controllers/favorite.controller.ts new file mode 100644 index 0000000..7e803a1 --- /dev/null +++ b/src/controllers/favorite.controller.ts @@ -0,0 +1,170 @@ +import { type Context } from "hono"; +import { UniqueConstraintError } from "sequelize"; +import res from "@/utils/response"; +import { UserRole, type IUserAttributes } from "@/models/User.model"; +import type { InferSchemaType } from "@/utils/validation"; +import favoriteValidation from "@/validation/favorite.validation"; +import favoriteRepository from "@/repository/favorite.repository"; + +const canAccessAllFiles = (user: IUserAttributes) => user.role === UserRole.ADMIN; + +const createFavorite = async (c: Context) => { + try { + type CreateFavoriteBody = InferSchemaType; + const value = c.get('validated'); + const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); + + const file = value.file_id + ? await favoriteRepository.findAccessibleFileById(value.file_id, user.id, hasGlobalFileAccess) + : await favoriteRepository.findAccessibleFileByStoragePath(value.storage_path, user.id, hasGlobalFileAccess); + + if (!file) { + return res.FailureResponse(c, 404, { message: "File not found or you do not have permission to favorite it" }); + } + + const favorite = await favoriteRepository.createFavorite({ + user_id: user.id, + file_id: file.id, + }); + + const favoriteWithFile = await favoriteRepository.getFavoriteById(favorite.id, user.id, hasGlobalFileAccess); + return res.SuccessResponse(c, 201, { message: "Favorite created successfully", data: { favorite: favoriteWithFile } }); + } catch (error: any) { + if (error instanceof UniqueConstraintError) { + return res.FailureResponse(c, 409, { message: "File is already in favorites" }); + } + + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const getFavorites = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); + const query = c.get('validatedQuery') as { + page: number; + limit: number; + search?: string; + sortBy: 'created_at' | 'name'; + sortOrder: 'ASC' | 'DESC' | 'asc' | 'desc'; + }; + + const result = await favoriteRepository.getFavorites(user.id, { + ...query, + sortOrder: query.sortOrder.toUpperCase() as 'ASC' | 'DESC', + canAccessAllFiles: hasGlobalFileAccess, + }); + + return res.SuccessResponse(c, 200, { message: "Favorites retrieved successfully", data: result }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const getFavoriteById = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); + const params = c.get('validatedParams') as { favoriteId: string }; + + const favorite = await favoriteRepository.getFavoriteById(params.favoriteId, user.id, hasGlobalFileAccess); + if (!favorite) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite retrieved successfully", data: { favorite } }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const getFavoriteByFileId = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); + const params = c.get('validatedParams') as { fileId: string }; + + const favorite = await favoriteRepository.getFavoriteByFileId(params.fileId, user.id, hasGlobalFileAccess); + return res.SuccessResponse(c, 200, { + message: "Favorite status retrieved successfully", + data: { + is_favorite: Boolean(favorite), + favorite, + }, + }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const updateFavorite = async (c: Context) => { + try { + type UpdateFavoriteBody = InferSchemaType; + const value = c.get('validated'); + const params = c.get('validatedParams') as { favoriteId: string }; + const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); + + const file = await favoriteRepository.findAccessibleFileById(value.file_id, user.id, hasGlobalFileAccess); + if (!file) { + return res.FailureResponse(c, 404, { message: "File not found or you do not have permission to favorite it" }); + } + + const favorite = await favoriteRepository.updateFavorite(params.favoriteId, user.id, value.file_id, hasGlobalFileAccess); + if (!favorite) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite updated successfully", data: { favorite } }); + } catch (error: any) { + if (error instanceof UniqueConstraintError) { + return res.FailureResponse(c, 409, { message: "File is already in favorites" }); + } + + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const deleteFavorite = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const params = c.get('validatedParams') as { favoriteId: string }; + + const deletedCount = await favoriteRepository.deleteFavorite(params.favoriteId, user.id); + if (deletedCount === 0) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite deleted successfully", data: { deletedCount } }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const deleteFavoriteByFileId = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const params = c.get('validatedParams') as { fileId: string }; + + const deletedCount = await favoriteRepository.deleteFavoriteByFileId(params.fileId, user.id); + if (deletedCount === 0) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite deleted successfully", data: { deletedCount } }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +export default { + createFavorite, + getFavorites, + getFavoriteById, + getFavoriteByFileId, + updateFavorite, + deleteFavorite, + deleteFavoriteByFileId, +}; diff --git a/src/global/routes.ts b/src/global/routes.ts index 8dae701..132b4ec 100644 --- a/src/global/routes.ts +++ b/src/global/routes.ts @@ -5,6 +5,7 @@ import { UploadRouter } from "@/routes/upload.routes"; import { NotificationRouter } from "@/routes/notification.routes"; import { ApiTokenRouter } from "@/routes/api-token.routes"; import { AnalyticsRouter } from "@/routes/analytics.routes"; +import { FavoriteRouter } from "@/routes/favorite.routes"; export class MainRouter { private readonly router: Hono; @@ -21,10 +22,11 @@ export class MainRouter { this.router.route("/notification", new NotificationRouter().getRouter()); this.router.route("/api-token", new ApiTokenRouter().getRouter()); this.router.route("/analytics", new AnalyticsRouter().getRouter()); + this.router.route("/favorite", new FavoriteRouter().getRouter()); } /** Return the configured Hono instance */ public getRouter(): Hono { return this.router; } -} \ No newline at end of file +} diff --git a/src/repository/favorite.repository.ts b/src/repository/favorite.repository.ts new file mode 100644 index 0000000..f4382ce --- /dev/null +++ b/src/repository/favorite.repository.ts @@ -0,0 +1,166 @@ +import db from "@/config/database"; +import type { FavoriteCreationAttributes } from "@/models/Favorite.model"; +import { Op, type Order } from "sequelize"; + +type FavoriteSortBy = 'created_at' | 'name'; +type SortOrder = 'ASC' | 'DESC'; + +type FavoriteListOptions = { + page: number; + limit: number; + search?: string; + sortBy: FavoriteSortBy; + sortOrder: SortOrder; + canAccessAllFiles?: boolean; +}; + +const favoriteInclude = (userId: string, search?: string, canAccessAllFiles: boolean = false) => ({ + model: db.File, + as: 'file', + required: true, + attributes: [ + 'id', + 'owner_id', + 'parent_id', + 'name', + 'is_folder', + 'access_level', + 'file_info', + 'description', + 'tags', + 'metadata', + 'last_accessed_at', + 'created_at', + 'updated_at', + ], + where: { + deleted_at: null, + ...(!canAccessAllFiles ? { owner_id: userId } : {}), + ...(search ? { name: { [Op.iLike]: `%${search}%` } } : {}), + }, +}); + +const findAccessibleFileById = async (fileId: string, userId: string, canAccessAllFiles: boolean = false) => { + return db.File.findOne({ + where: { + id: fileId, + deleted_at: null, + ...(!canAccessAllFiles ? { owner_id: userId } : {}), + }, + attributes: ['id'], + }); +}; + +const findAccessibleFileByStoragePath = async (storagePath: string, userId: string, canAccessAllFiles: boolean = false) => { + return db.File.findOne({ + where: { + deleted_at: null, + ...(!canAccessAllFiles ? { owner_id: userId } : {}), + file_info: { + storage_path: storagePath, + }, + }, + attributes: ['id'], + }); +}; + +const createFavorite = async (favorite: FavoriteCreationAttributes) => { + return db.Favorite.create(favorite); +}; + +const getFavorites = async (userId: string, options: FavoriteListOptions) => { + const safePage = Math.max(1, Math.trunc(options.page)); + const safeLimit = Math.min(100, Math.max(1, Math.trunc(options.limit))); + const offset = (safePage - 1) * safeLimit; + const order: Order = options.sortBy === 'name' + ? [[{ model: db.File, as: 'file' }, 'name', options.sortOrder]] + : [['created_at', options.sortOrder]]; + + const { rows, count } = await db.Favorite.findAndCountAll({ + where: { user_id: userId }, + attributes: ['id', 'user_id', 'file_id', 'created_at'], + include: [favoriteInclude(userId, options.search, options.canAccessAllFiles)], + order, + limit: safeLimit, + offset, + distinct: true, + }); + + return { + favorites: rows, + metadata: { + total: count, + page: safePage, + limit: safeLimit, + totalPages: Math.ceil(count / safeLimit), + }, + }; +}; + +const getFavoriteById = async (favoriteId: string, userId: string, canAccessAllFiles: boolean = false) => { + return db.Favorite.findOne({ + where: { + id: favoriteId, + user_id: userId, + }, + attributes: ['id', 'user_id', 'file_id', 'created_at'], + include: [favoriteInclude(userId, undefined, canAccessAllFiles)], + }); +}; + +const getFavoriteByFileId = async (fileId: string, userId: string, canAccessAllFiles: boolean = false) => { + return db.Favorite.findOne({ + where: { + file_id: fileId, + user_id: userId, + }, + attributes: ['id', 'user_id', 'file_id', 'created_at'], + include: [favoriteInclude(userId, undefined, canAccessAllFiles)], + }); +}; + +const updateFavorite = async (favoriteId: string, userId: string, fileId: string, canAccessAllFiles: boolean = false) => { + const favorite = await db.Favorite.findOne({ + where: { + id: favoriteId, + user_id: userId, + }, + }); + + if (!favorite) { + return null; + } + + await favorite.update({ file_id: fileId }); + return getFavoriteById(favoriteId, userId, canAccessAllFiles); +}; + +const deleteFavorite = async (favoriteId: string, userId: string) => { + return db.Favorite.destroy({ + where: { + id: favoriteId, + user_id: userId, + }, + }); +}; + +const deleteFavoriteByFileId = async (fileId: string, userId: string) => { + return db.Favorite.destroy({ + where: { + file_id: fileId, + user_id: userId, + }, + }); +}; + +export default { + findAccessibleFileById, + findAccessibleFileByStoragePath, + createFavorite, + getFavorites, + getFavoriteById, + getFavoriteByFileId, + updateFavorite, + deleteFavorite, + deleteFavoriteByFileId, +}; diff --git a/src/routes/favorite.routes.ts b/src/routes/favorite.routes.ts new file mode 100644 index 0000000..5193949 --- /dev/null +++ b/src/routes/favorite.routes.ts @@ -0,0 +1,30 @@ +import { Hono } from "hono"; +import AuthMiddleware from "@/middleware/auth.middleware"; +import favoriteController from "@/controllers/favorite.controller"; +import { validateBody, validateParams, validateQuery } from "@/utils/validation"; +import favoriteValidation from "@/validation/favorite.validation"; + +export class FavoriteRouter { + private readonly router: Hono; + + constructor() { + this.router = new Hono(); + this.initializeRoutes(); + } + + private initializeRoutes() { + this.router.use(AuthMiddleware.authMiddleware); + + this.router.post('/', validateBody(favoriteValidation.createFavoriteValidation), favoriteController.createFavorite); + this.router.get('/', validateQuery(favoriteValidation.getFavoritesValidation), favoriteController.getFavorites); + this.router.get('/file/:fileId', validateParams(favoriteValidation.favoriteFileIdValidation), favoriteController.getFavoriteByFileId); + this.router.delete('/file/:fileId', validateParams(favoriteValidation.favoriteFileIdValidation), favoriteController.deleteFavoriteByFileId); + this.router.get('/:favoriteId', validateParams(favoriteValidation.favoriteIdValidation), favoriteController.getFavoriteById); + this.router.patch('/:favoriteId', validateParams(favoriteValidation.favoriteIdValidation), validateBody(favoriteValidation.updateFavoriteValidation), favoriteController.updateFavorite); + this.router.delete('/:favoriteId', validateParams(favoriteValidation.favoriteIdValidation), favoriteController.deleteFavorite); + } + + public getRouter() { + return this.router; + } +} diff --git a/src/validation/favorite.validation.ts b/src/validation/favorite.validation.ts new file mode 100644 index 0000000..9f1d745 --- /dev/null +++ b/src/validation/favorite.validation.ts @@ -0,0 +1,111 @@ +import Joi from 'joi'; + +const favoriteIdValidation = Joi.object({ + favoriteId: Joi.string() + .uuid() + .required() + .messages({ + 'string.guid': 'Favorite ID must be a valid UUID', + 'any.required': 'Favorite ID is required', + }), +}); + +const favoriteFileIdValidation = Joi.object({ + fileId: Joi.string() + .uuid() + .required() + .messages({ + 'string.guid': 'File ID must be a valid UUID', + 'any.required': 'File ID is required', + }), +}); + +const createFavoriteValidation = Joi.object({ + file_id: Joi.string() + .uuid() + .optional() + .messages({ + 'string.guid': 'File ID must be a valid UUID', + }), + storage_path: Joi.string() + .trim() + .min(1) + .max(2048) + .optional() + .messages({ + 'string.empty': 'Storage path is required', + 'string.min': 'Storage path is required', + 'string.max': 'Storage path cannot exceed 2048 characters', + }), +}) + .xor('file_id', 'storage_path') + .messages({ + 'object.missing': 'Either file_id or storage_path is required', + 'object.xor': 'Provide either file_id or storage_path, not both', + }); + +const updateFavoriteValidation = Joi.object({ + file_id: Joi.string() + .uuid() + .required() + .messages({ + 'string.guid': 'File ID must be a valid UUID', + 'any.required': 'File ID is required', + }), +}); + +const getFavoritesValidation = Joi.object({ + page: Joi.number() + .integer() + .min(1) + .optional() + .default(1) + .messages({ + 'number.base': 'Page should be a number', + 'number.integer': 'Page should be an integer', + 'number.min': 'Page should be at least 1', + }), + limit: Joi.number() + .integer() + .min(1) + .max(100) + .optional() + .default(20) + .messages({ + 'number.base': 'Limit should be a number', + 'number.integer': 'Limit should be an integer', + 'number.min': 'Limit should be at least 1', + 'number.max': 'Limit cannot exceed 100', + }), + search: Joi.string() + .trim() + .min(1) + .max(255) + .optional() + .messages({ + 'string.min': 'Search text cannot be empty', + 'string.max': 'Search text cannot exceed 255 characters', + }), + sortBy: Joi.string() + .valid('created_at', 'name') + .optional() + .default('created_at') + .messages({ + 'any.only': 'Sort by must be one of created_at or name', + }), + sortOrder: Joi.string() + .valid('ASC', 'DESC', 'asc', 'desc') + .optional() + .default('DESC') + .messages({ + 'any.only': 'Sort order must be ASC or DESC', + }), +}); + +export default { + favoriteIdValidation, + favoriteFileIdValidation, + createFavoriteValidation, + updateFavoriteValidation, + getFavoritesValidation, +};