Skip to content

Commit ede3df6

Browse files
authored
Merge pull request #1056 from internxt/feature/return-parent-folder-name-for-trashed-items
[PB-5819]: feat/add parent folder name when returning trashed items
2 parents 870a2ce + 0a47abc commit ede3df6

8 files changed

Lines changed: 595 additions & 104 deletions

src/modules/file/file.repository.spec.ts

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import { Test, type TestingModule } from '@nestjs/testing';
2-
3-
jest.mock('../../lib/query-timeout', () => ({
4-
withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})),
5-
}));
62
import { getModelToken } from '@nestjs/sequelize';
73
import { createMock } from '@golevelup/ts-jest';
84
import {
@@ -23,6 +19,10 @@ import { UserModel } from '../user/user.model';
2319
import { WorkspaceItemUserModel } from '../workspaces/models/workspace-items-users.model';
2420
import { Time } from '../../lib/time';
2521

22+
jest.mock('../../lib/query-timeout', () => ({
23+
withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})),
24+
}));
25+
2626
describe('FileRepository', () => {
2727
let repository: FileRepository;
2828
let fileModel: typeof FileModel;
@@ -534,13 +534,66 @@ describe('FileRepository', () => {
534534
});
535535
});
536536

537+
describe('findTrashedNotExpired', () => {
538+
const userId = 1;
539+
const limit = 10;
540+
const offset = 0;
541+
542+
it('When no expiration date is set, then it should return all trashed files regardless of when they were trashed', async () => {
543+
jest.spyOn(fileModel, 'findAll').mockResolvedValue([]);
544+
545+
await repository.findTrashedNotExpired(userId, null, limit, offset);
546+
547+
expect(fileModel.findAll).toHaveBeenCalledWith(
548+
expect.objectContaining({
549+
where: { userId, status: FileStatus.TRASHED },
550+
}),
551+
);
552+
});
553+
554+
it('When an expiration date is set, then it should only return trashed files that have not yet expired', async () => {
555+
const cutoffDate = new Date('2026-03-04');
556+
jest.spyOn(fileModel, 'findAll').mockResolvedValue([]);
557+
558+
await repository.findTrashedNotExpired(userId, cutoffDate, limit, offset);
559+
560+
expect(fileModel.findAll).toHaveBeenCalledWith(
561+
expect.objectContaining({
562+
where: {
563+
userId,
564+
status: FileStatus.TRASHED,
565+
updatedAt: { [Op.gte]: cutoffDate },
566+
},
567+
}),
568+
);
569+
});
570+
571+
it('When retrieving trashed files, then it should also load the name of the folder each file belongs to', async () => {
572+
jest.spyOn(fileModel, 'findAll').mockResolvedValue([]);
573+
574+
await repository.findTrashedNotExpired(userId, null, limit, offset);
575+
576+
expect(fileModel.findAll).toHaveBeenCalledWith(
577+
expect.objectContaining({
578+
include: expect.arrayContaining([
579+
expect.objectContaining({
580+
as: 'folder',
581+
attributes: ['plainName', 'removed', 'deleted', 'uuid'],
582+
required: false,
583+
}),
584+
]),
585+
}),
586+
);
587+
});
588+
});
589+
537590
describe('findTrashedNotExpiredInWorkspace', () => {
538591
const createdBy = v4();
539592
const workspaceId = v4();
540593
const limit = 10;
541594
const offset = 0;
542595

543-
it('When cutoffDate is null, then it should query trashed files without a date filter', async () => {
596+
it('When no expiration date is set, then it should return all trashed workspace files regardless of when they were trashed', async () => {
544597
jest.spyOn(fileModel, 'findAll').mockResolvedValue([]);
545598

546599
await repository.findTrashedNotExpiredInWorkspace(
@@ -558,7 +611,7 @@ describe('FileRepository', () => {
558611
);
559612
});
560613

561-
it('When cutoffDate is provided, then it should add an updatedAt >= cutoffDate filter', async () => {
614+
it('When an expiration date is set, then it should only return workspace trashed files that have not yet expired', async () => {
562615
const cutoffDate = new Date('2026-03-04');
563616
jest.spyOn(fileModel, 'findAll').mockResolvedValue([]);
564617

@@ -579,6 +632,30 @@ describe('FileRepository', () => {
579632
}),
580633
);
581634
});
635+
636+
it('When retrieving workspace trashed files, then it should also load the name of the folder each file belongs to', async () => {
637+
jest.spyOn(fileModel, 'findAll').mockResolvedValue([]);
638+
639+
await repository.findTrashedNotExpiredInWorkspace(
640+
createdBy,
641+
workspaceId,
642+
null,
643+
limit,
644+
offset,
645+
);
646+
647+
expect(fileModel.findAll).toHaveBeenCalledWith(
648+
expect.objectContaining({
649+
include: expect.arrayContaining([
650+
expect.objectContaining({
651+
as: 'folder',
652+
attributes: ['plainName', 'removed', 'deleted', 'uuid'],
653+
required: false,
654+
}),
655+
]),
656+
}),
657+
);
658+
});
582659
});
583660

584661
describe('findRecent', () => {

src/modules/file/file.repository.ts

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from './file.domain';
1010
import {
1111
type FindOptions,
12+
type Includeable,
1213
Op,
1314
QueryTypes,
1415
Sequelize,
@@ -422,7 +423,7 @@ export class SequelizeFileRepository implements FileRepository {
422423
structuredClone(order);
423424
const [, orderDirection] = order[plainNameIndex];
424425
newOrder[plainNameIndex] = Sequelize.literal(
425-
`plain_name COLLATE "custom_numeric" ${
426+
`"FileModel"."plain_name" COLLATE "custom_numeric" ${
426427
orderDirection === 'ASC' ? 'ASC' : 'DESC'
427428
}`,
428429
);
@@ -456,16 +457,15 @@ export class SequelizeFileRepository implements FileRepository {
456457
offset: number,
457458
order: Array<[keyof FileModel, string]> = [],
458459
): Promise<File[]> {
459-
return this.findAllCursorWithThumbnails(
460-
{
461-
userId,
462-
status: FileStatus.TRASHED,
463-
...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }),
464-
},
460+
return this.trashedNotExpiredQuery({
461+
cutoffDate,
465462
limit,
466463
offset,
467464
order,
468-
);
465+
where: {
466+
userId,
467+
},
468+
});
469469
}
470470

471471
async findTrashedNotExpiredInWorkspace(
@@ -476,17 +476,76 @@ export class SequelizeFileRepository implements FileRepository {
476476
offset: number,
477477
order: Array<[keyof FileModel, string]> = [],
478478
): Promise<File[]> {
479-
return this.findAllCursorWithThumbnailsInWorkspace(
480-
createdBy,
481-
workspaceId,
482-
{
483-
status: FileStatus.TRASHED,
484-
...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }),
485-
},
479+
return this.trashedNotExpiredQuery({
480+
cutoffDate,
486481
limit,
487482
offset,
488483
order,
489-
);
484+
include: [
485+
{
486+
model: WorkspaceItemUserModel,
487+
where: {
488+
createdBy,
489+
workspaceId,
490+
itemType: WorkspaceItemType.File,
491+
},
492+
as: 'workspaceUser',
493+
include: [
494+
{
495+
model: UserModel,
496+
as: 'creator',
497+
attributes: ['uuid', 'email', 'name', 'lastname', 'userId'],
498+
required: true,
499+
},
500+
],
501+
},
502+
],
503+
});
504+
}
505+
506+
async trashedNotExpiredQuery({
507+
cutoffDate,
508+
limit,
509+
offset,
510+
order = [],
511+
where,
512+
include = [],
513+
}: {
514+
cutoffDate: Date | null;
515+
limit: number;
516+
offset: number;
517+
order: Array<[keyof FileModel, string]>;
518+
where?: WhereOptions<any>;
519+
include?: Includeable[];
520+
}): Promise<File[]> {
521+
const appliedOrder = this.applyCollateToPlainNameSort(order);
522+
523+
const files = await this.fileModel.findAll({
524+
limit,
525+
offset,
526+
where: {
527+
status: FileStatus.TRASHED,
528+
...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }),
529+
...where,
530+
},
531+
include: [
532+
{
533+
model: FolderModel,
534+
as: 'folder',
535+
attributes: ['plainName', 'removed', 'deleted', 'uuid'],
536+
required: false,
537+
},
538+
{
539+
model: this.thumbnailModel,
540+
required: false,
541+
},
542+
...include,
543+
],
544+
subQuery: false,
545+
order: appliedOrder,
546+
});
547+
548+
return files.map(this.toDomain.bind(this));
490549
}
491550

492551
async findAllCursorWhereUpdatedAfterInWorkspace(
@@ -1087,9 +1146,9 @@ export class SequelizeFileRepository implements FileRepository {
10871146
status: FileStatus.DELETED,
10881147
updatedAt: { [Op.lt]: cutoffDate },
10891148
},
1149+
order: [['updatedAt', 'ASC']],
10901150
useMaster: opts?.useMaster,
10911151
limit,
1092-
order: [['updatedAt', 'ASC']],
10931152
});
10941153

10951154
return rows.map((r) => r.uuid);

src/modules/folder/folder.repository.spec.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import { createMock } from '@golevelup/ts-jest';
2-
3-
jest.mock('../../lib/query-timeout', () => ({
4-
withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})),
5-
}));
62
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';
73
import { SequelizeFolderRepository } from './folder.repository';
84
import { FolderModel } from './folder.model';
@@ -19,6 +15,10 @@ import { v4 } from 'uuid';
1915
import { randomInt } from 'crypto';
2016
import { Time } from '../../lib/time';
2117

18+
jest.mock('../../lib/query-timeout', () => ({
19+
withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})),
20+
}));
21+
2222
jest.mock('./folder.model', () => ({
2323
FolderModel: {
2424
sequelize: {
@@ -352,13 +352,67 @@ describe('SequelizeFolderRepository', () => {
352352
});
353353
});
354354

355+
describe('findTrashedNotExpired', () => {
356+
const userId = 1;
357+
const limit = 10;
358+
const offset = 0;
359+
360+
it('When no expiration date is set, then it should return all trashed folders regardless of when they were trashed', async () => {
361+
jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]);
362+
363+
await repository.findTrashedNotExpired(userId, null, limit, offset);
364+
365+
expect(folderModel.findAll).toHaveBeenCalledWith(
366+
expect.objectContaining({
367+
where: { userId, deleted: true, removed: false },
368+
}),
369+
);
370+
});
371+
372+
it('When an expiration date is set, then it should only return trashed folders that have not yet expired', async () => {
373+
const cutoffDate = new Date('2026-03-04');
374+
jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]);
375+
376+
await repository.findTrashedNotExpired(userId, cutoffDate, limit, offset);
377+
378+
expect(folderModel.findAll).toHaveBeenCalledWith(
379+
expect.objectContaining({
380+
where: {
381+
userId,
382+
deleted: true,
383+
removed: false,
384+
updatedAt: { [Op.gte]: cutoffDate },
385+
},
386+
}),
387+
);
388+
});
389+
390+
it('When retrieving trashed folders, then it should also load the name of the parent folder each folder belongs to', async () => {
391+
jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]);
392+
393+
await repository.findTrashedNotExpired(userId, null, limit, offset);
394+
395+
expect(folderModel.findAll).toHaveBeenCalledWith(
396+
expect.objectContaining({
397+
include: expect.arrayContaining([
398+
expect.objectContaining({
399+
as: 'parent',
400+
attributes: ['plainName', 'removed', 'deleted', 'uuid'],
401+
required: false,
402+
}),
403+
]),
404+
}),
405+
);
406+
});
407+
});
408+
355409
describe('findTrashedNotExpiredInWorkspace', () => {
356410
const createdBy = v4();
357411
const workspaceId = v4();
358412
const limit = 10;
359413
const offset = 0;
360414

361-
it('When cutoffDate is null, then it should query trashed folders without a date filter', async () => {
415+
it('When no expiration date is set, then it should return all trashed workspace folders regardless of when they were trashed', async () => {
362416
jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]);
363417

364418
await repository.findTrashedNotExpiredInWorkspace(
@@ -376,7 +430,7 @@ describe('SequelizeFolderRepository', () => {
376430
);
377431
});
378432

379-
it('When cutoffDate is provided, then it should add an updatedAt >= cutoffDate filter', async () => {
433+
it('When an expiration date is set, then it should only return workspace trashed folders that have not yet expired', async () => {
380434
const cutoffDate = new Date('2026-03-04');
381435
jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]);
382436

@@ -398,6 +452,30 @@ describe('SequelizeFolderRepository', () => {
398452
}),
399453
);
400454
});
455+
456+
it('When retrieving workspace trashed folders, then it should also load the name of the parent folder each folder belongs to', async () => {
457+
jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]);
458+
459+
await repository.findTrashedNotExpiredInWorkspace(
460+
createdBy,
461+
workspaceId,
462+
null,
463+
limit,
464+
offset,
465+
);
466+
467+
expect(folderModel.findAll).toHaveBeenCalledWith(
468+
expect.objectContaining({
469+
include: expect.arrayContaining([
470+
expect.objectContaining({
471+
as: 'parent',
472+
attributes: ['plainName', 'removed', 'deleted', 'uuid'],
473+
required: false,
474+
}),
475+
]),
476+
}),
477+
);
478+
});
401479
});
402480

403481
describe('createWithAttributes', () => {

0 commit comments

Comments
 (0)