Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PB-2217]: feat/get-file-by-path #326

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d6a419d
added folders depth migration
larryrider Jun 4, 2024
ed14c16
added depth attributte to folders
larryrider Jun 4, 2024
b0d525a
removed unuseful comment
larryrider Jun 5, 2024
0de3e10
added get folders searching by depth and name
larryrider Jun 6, 2024
d736fec
added get file by path endpoint functionality
larryrider Jun 6, 2024
a49e25e
added some refactor and testing
larryrider Jun 10, 2024
15b9bb6
added one more test at file controller
larryrider Jun 10, 2024
5e527a1
Merge branch 'master' into feat/pb-2217-get-file-by-path
larry-internxt Jun 10, 2024
dc79fa1
added file usecase tests
larryrider Jun 10, 2024
33bea4a
added file usecase test
larryrider Jun 11, 2024
d76c53e
fixed utf8 base64 encoded param
larryrider Jun 11, 2024
a89358e
changed default depth value from null to undefined
larryrider Jun 13, 2024
3e9e2a5
changed default depth value from null to undefined
larryrider Jun 13, 2024
cce0b44
Merge branch 'master' into feat/pb-2217-get-file-by-path
larry-internxt Jun 14, 2024
82b7f3f
Merge branch 'master' into feat/add-folders-depth
larry-internxt Jun 14, 2024
f037f5d
added missing depth attr
larryrider Jun 14, 2024
1c6915d
Merge branch 'feat/add-folders-depth' of internxt:internxt/drive-serv…
larryrider Jun 14, 2024
8d15168
Merge branch 'feat/add-folders-depth' into feat/pb-2217-get-file-by-path
larryrider Jun 14, 2024
c67a4a5
Merge branch 'master' into feat/add-folders-depth
larryrider Sep 25, 2024
1d2b3f8
Merge branch 'feat/add-folders-depth' into feat/pb-2217-get-file-by-path
larryrider Sep 25, 2024
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
128 changes: 128 additions & 0 deletions migrations/20240531090232-add-folders-depth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use strict';

const tableName = 'folders';
const columName = 'depth';
const indexName = `${tableName}_${columName}_index`;

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(tableName, columName, {
type: Sequelize.INTEGER,
allowNull: true,
});

await queryInterface.sequelize.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexName} ON ${tableName} (${columName})`,
);

await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION calculate_folder_depth(parentId UUID)
RETURNS INT AS $$
DECLARE
depth INT;
BEGIN
depth := 0;

IF parentId IS NOT NULL THEN
SELECT f.depth + 1 INTO depth FROM folders f WHERE f.uuid = parentId;
END IF;

RETURN depth;
END;
$$ LANGUAGE plpgsql;
`);

await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION before_insert_update_folder_depth()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.depth := calculate_folder_depth(NEW.parent_uuid);
ELSIF TG_OP = 'UPDATE' THEN
IF NEW.parent_uuid IS DISTINCT FROM OLD.parent_uuid THEN
NEW.depth := calculate_folder_depth(NEW.parent_uuid);
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE TRIGGER before_insert_folder_depth
BEFORE INSERT ON folders
FOR EACH ROW
EXECUTE FUNCTION before_insert_update_folder_depth();

CREATE OR REPLACE TRIGGER before_update_folder_depth
BEFORE UPDATE ON folders
FOR EACH ROW
EXECUTE FUNCTION before_insert_update_folder_depth();
`);

await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION update_child_folders_depth(parentId UUID, parentDepth INT)
RETURNS VOID AS $$
DECLARE
child RECORD;
BEGIN
FOR child IN
SELECT uuid FROM folders WHERE parent_uuid = parentId
LOOP
UPDATE folders SET depth = parentDepth + 1 WHERE uuid = child.uuid;

PERFORM update_child_folders_depth(child.uuid, parentDepth + 1);
END LOOP;
END;
$$ LANGUAGE plpgsql;
`);

await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION after_update_folder_depth()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.depth IS DISTINCT FROM OLD.depth THEN
PERFORM update_child_folders_depth(NEW.uuid, NEW.depth);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE TRIGGER after_update_folder_depth
AFTER UPDATE ON folders
FOR EACH ROW
EXECUTE FUNCTION after_update_folder_depth();
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(
'DROP TRIGGER IF EXISTS after_update_folder_depth on folders;',
);
await queryInterface.sequelize.query(
'DROP FUNCTION IF EXISTS after_update_folder_depth;',
);

await queryInterface.sequelize.query(
'DROP FUNCTION IF EXISTS update_child_folders_depth;',
);

await queryInterface.sequelize.query(
'DROP TRIGGER IF EXISTS before_insert_folder_depth on folders;',
);
await queryInterface.sequelize.query(
'DROP TRIGGER IF EXISTS before_update_folder_depth on folders;',
);
await queryInterface.sequelize.query(
'DROP FUNCTION IF EXISTS before_insert_update_folder_depth;',
);

await queryInterface.sequelize.query(
'DROP FUNCTION IF EXISTS calculate_folder_depth;',
);

await queryInterface.sequelize.query(
`DROP INDEX CONCURRENTLY IF EXISTS ${indexName}`,
);
await queryInterface.removeColumn(tableName, columName);
},
};
117 changes: 116 additions & 1 deletion src/modules/file/file.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { v4 } from 'uuid';
import { newFile, newFolder } from '../../../test/fixtures';
import { FileUseCases } from './file.usecase';
import { User } from '../user/user.domain';
import { File, FileStatus } from './file.domain';
import { FileController } from './file.controller';
import { FolderUseCases } from '../folder/folder.usecase';

describe('FileController', () => {
let fileController: FileController;
let fileUseCases: FileUseCases;
let folderUseCases: FolderUseCases;
let file: File;

const userMocked = User.build({
Expand Down Expand Up @@ -53,6 +55,7 @@ describe('FileController', () => {

fileController = module.get<FileController>(FileController);
fileUseCases = module.get<FileUseCases>(FileUseCases);
folderUseCases = module.get<FolderUseCases>(FolderUseCases);
file = newFile();
});

Expand Down Expand Up @@ -107,4 +110,116 @@ describe('FileController', () => {
).rejects.toThrow(BadRequestException);
});
});

describe('get file by path', () => {
it('When get file metadata by path is requested with a valid path, then the file is returned', async () => {
const expectedFile = newFile();
const filePath = Buffer.from('/test/file.png', 'utf-8').toString(
'base64',
);
jest
.spyOn(fileUseCases, 'getFilesByPathAndUser')
.mockResolvedValue([expectedFile]);

const result = await fileController.getFileMetaByPath(
userMocked,
filePath,
);
expect(result).toEqual({ file: expectedFile });
});

it('When get file metadata by path is requested with a valid path and multiple posibble files, then the file is returned', async () => {
const firstAncestorFolder1 = newFolder({
attributes: {
name: 'test',
plainName: 'test',
},
});
const firstAncestorFolder2 = newFolder({
attributes: {
name: 'test2',
plainName: 'test2',
},
});
const possibleFolder1 = newFolder({
attributes: {
name: 'folder',
plainName: 'folder',
parent: firstAncestorFolder1,
parentId: firstAncestorFolder1.id,
parentUuid: firstAncestorFolder1.uuid,
},
});
const possibleFolder2 = newFolder({
attributes: {
name: 'folder',
plainName: 'folder',
parent: firstAncestorFolder2,
parentId: firstAncestorFolder2.id,
parentUuid: firstAncestorFolder2.uuid,
},
});
const possibleFile1 = newFile({
attributes: {
name: 'file',
type: 'png',
folder: possibleFolder1,
folderId: possibleFolder1.id,
folderUuid: possibleFolder1.uuid,
},
});
const possibleFile2 = newFile({
attributes: {
name: 'file',
type: 'png',
folder: possibleFolder2,
folderId: possibleFolder2.id,
folderUuid: possibleFolder2.uuid,
},
});
const completePath = `/${firstAncestorFolder1.name}/${possibleFolder1.name}/${possibleFile1.name}.${possibleFile1.type}`;
const filePath = Buffer.from(completePath, 'utf-8').toString('base64');
jest
.spyOn(fileUseCases, 'getFilesByPathAndUser')
.mockResolvedValue([possibleFile1, possibleFile2]);
jest
.spyOn(fileUseCases, 'getPathFirstFolder')
.mockReturnValue(possibleFolder1.name);
jest
.spyOn(folderUseCases, 'getFolderAncestors')
.mockResolvedValueOnce([newFolder(), possibleFolder2, newFolder()])
.mockResolvedValueOnce([newFolder(), possibleFolder1, newFolder()]);

const result = await fileController.getFileMetaByPath(
userMocked,
filePath,
);
expect(result).toEqual({ file: possibleFile1 });
});

it('When get file metadata by path is requested with a valid path that not exists, then it should throw a not found error', async () => {
const filePath = Buffer.from('/test/file.png', 'binary').toString(
'base64',
);
jest.spyOn(fileUseCases, 'getFilesByPathAndUser').mockResolvedValue([]);

expect(
fileController.getFileMetaByPath(userMocked, filePath),
).rejects.toThrow(NotFoundException);
});

it('When get file metadata by path is requested with an invalid path, then it should throw an error', () => {
expect(
fileController.getFileMetaByPath(userMocked, 'invalidpath'),
).rejects.toThrow(BadRequestException);

expect(fileController.getFileMetaByPath(userMocked, '')).rejects.toThrow(
BadRequestException,
);

expect(
fileController.getFileMetaByPath(userMocked, '/path/notBase64Encoded'),
).rejects.toThrow(BadRequestException);
});
});
});
59 changes: 59 additions & 0 deletions src/modules/file/file.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { SharingPermissionsGuard } from '../sharing/guards/sharing-permissions.g
import { GetDataFromRequest } from '../../common/extract-data-from-request';
import { StorageNotificationService } from '../../externals/notifications/storage.notifications.service';
import { Client } from '../auth/decorators/client.decorator';
import { FolderUseCases } from '../folder/folder.usecase';

const filesStatuses = ['ALL', 'EXISTS', 'TRASHED', 'DELETED'] as const;

Expand All @@ -47,6 +48,7 @@ export class FileController {
constructor(
private readonly fileUseCases: FileUseCases,
private readonly storageNotificationService: StorageNotificationService,
private readonly folderUseCases: FolderUseCases,
) {}

@Post('/')
Expand Down Expand Up @@ -334,4 +336,61 @@ export class FileController {

return file;
}

@Get('/meta')
async getFileMetaByPath(
@UserDecorator() user: User,
@Query('path') encodedPath: string,
) {
const filePath = Buffer.from(encodedPath, 'base64').toString('utf-8');
if (!filePath || filePath.length === 0 || !filePath.includes('/')) {
throw new BadRequestException('Invalid path provided');
}

try {
const possibleFiles = await this.fileUseCases.getFilesByPathAndUser(
filePath,
user.id,
);

if (possibleFiles.length === 0) {
throw new NotFoundException('File not found');
}

if (possibleFiles.length === 1) {
return { file: possibleFiles[0] };
} else {
// If there are multiple files under the same depth and filename, we can use the ancestors to indentify the correct file
const firstFolder = this.fileUseCases.getPathFirstFolder(filePath);
for (const possibleFile of possibleFiles) {
const ancestors = await this.folderUseCases.getFolderAncestors(
user,
possibleFile.folderUuid,
);
const firstAncestorPosition = ancestors.length - 2; // the last ancestor folder from the array is the root folder
if (firstAncestorPosition >= 0) {
const firstAncestor = ancestors[firstAncestorPosition];

if (firstAncestor.plainName === firstFolder) {
return { file: possibleFile };
}
}
}
throw new NotFoundException('File not found');
}
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}

const { email, uuid } = user;
const err = error as Error;

new Logger().error(
`[FILE/METADATABYPATH] ERROR: ${err.message}, CONTEXT ${JSON.stringify({
user: { email, uuid },
})} STACK: ${err.stack || 'NO STACK'}`,
);
}
}
}
17 changes: 17 additions & 0 deletions src/modules/file/file.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ export class SequelizeFileRepository implements FileRepository {
return file ? this.toDomain(file) : null;
}

async findByPlainNameAndFolderUuid(
plainName: FileAttributes['name'],
type: FileAttributes['type'],
folderUuid: FileAttributes['folderUuid'],
status: FileAttributes['status'],
): Promise<File | null> {
const file = await this.fileModel.findOne({
where: {
plainName: { [Op.eq]: plainName },
type: { [Op.eq]: type },
folderUuid: { [Op.eq]: folderUuid },
status: { [Op.eq]: status },
},
});
return file ? this.toDomain(file) : null;
}

async findAllCursorWhereUpdatedAfter(
where: Partial<FileAttributes>,
updatedAtAfter: Date,
Expand Down
Loading
Loading