Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

170 changes: 170 additions & 0 deletions src/controllers/favorite.controller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof favoriteValidation.createFavoriteValidation>;
const value = c.get<CreateFavoriteBody>('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<typeof favoriteValidation.updateFavoriteValidation>;
const value = c.get<UpdateFavoriteBody>('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,
};
4 changes: 3 additions & 1 deletion src/global/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
}
166 changes: 166 additions & 0 deletions src/repository/favorite.repository.ts
Original file line number Diff line number Diff line change
@@ -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,
};
30 changes: 30 additions & 0 deletions src/routes/favorite.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading