feat (server, web): Implement Archive (#2225)

* feat (server, web): add archive

* chore: generate api

* feat (web): add empty placeholder for archive page

* chore: remove title on favorites page

Duplicates sidebar selection. Two pages (Archive and Favorites)
are consistent now

* refactor (web): create EmptyPlaceholder component for empty pages

* fixed menu close button not close:

* fix (web): remove not necessary store call

* test (web): simplify asset tests code

* test (web): simplify asset tests code

* chore (server): remove isArchived while uploading

* chore (server): remove isArchived from typesense schema

* chore: generate api

* fix (web): delete asset from archive page

* chore: change archive asset count endpoint

old endpoint: /asset/archived-count-by-user-id
new endpoint: /asset/stat/archive

* chore: generate api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Sergey Kondrikov
2023-04-12 18:37:52 +03:00
committed by GitHub
parent eb9481b668
commit d314805caf
39 changed files with 861 additions and 97 deletions

View File

@@ -36,6 +36,7 @@ export interface IAssetRepository {
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getArchivedAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
getExistingAssets(
@@ -83,26 +84,22 @@ export class AssetRepository implements IAssetRepository {
.groupBy('asset.type')
.getRawMany();
const assetCountByUserId = new AssetCountByUserIdResponseDto();
return this.getAssetCount(items);
}
// asset type to dto property mapping
const map: Record<AssetType, keyof AssetCountByUserIdResponseDto> = {
[AssetType.AUDIO]: 'audio',
[AssetType.IMAGE]: 'photos',
[AssetType.VIDEO]: 'videos',
[AssetType.OTHER]: 'other',
};
async getArchivedAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
// Get archived asset count by AssetType
const items = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"ownerId" = :ownerId', { ownerId: ownerId })
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = true')
.groupBy('asset.type')
.getRawMany();
for (const item of items) {
const count = Number(item.count) || 0;
const assetType = item.type as AssetType;
const type = map[assetType];
assetCountByUserId[type] = count;
assetCountByUserId.total += count;
}
return assetCountByUserId;
return this.getAssetCount(items);
}
async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
@@ -115,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
})
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.orderBy('asset.fileCreatedAt', 'DESC')
.getMany();
}
@@ -130,6 +128,7 @@ export class AssetRepository implements IAssetRepository {
.where('"ownerId" = :userId', { userId: userId })
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.groupBy(`date_trunc('month', "fileCreatedAt")`)
.orderBy(`date_trunc('month', "fileCreatedAt")`, 'DESC')
.getRawMany();
@@ -141,6 +140,7 @@ export class AssetRepository implements IAssetRepository {
.where('"ownerId" = :userId', { userId: userId })
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.groupBy(`date_trunc('day', "fileCreatedAt")`)
.orderBy(`date_trunc('day', "fileCreatedAt")`, 'DESC')
.getRawMany();
@@ -224,6 +224,7 @@ export class AssetRepository implements IAssetRepository {
resizePath: Not(IsNull()),
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
},
relations: {
exifInfo: true,
@@ -260,6 +261,7 @@ export class AssetRepository implements IAssetRepository {
*/
async update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
asset.isArchived = dto.isArchived ?? asset.isArchived;
if (dto.tagIds) {
const tags = await this._tagRepository.getByIds(userId, dto.tagIds);
@@ -330,4 +332,27 @@ export class AssetRepository implements IAssetRepository {
},
});
}
private getAssetCount(items: any): AssetCountByUserIdResponseDto {
const assetCountByUserId = new AssetCountByUserIdResponseDto();
// asset type to dto property mapping
const map: Record<AssetType, keyof AssetCountByUserIdResponseDto> = {
[AssetType.AUDIO]: 'audio',
[AssetType.IMAGE]: 'photos',
[AssetType.VIDEO]: 'videos',
[AssetType.OTHER]: 'other',
};
for (const item of items) {
const count = Number(item.count) || 0;
const assetType = item.type as AssetType;
const type = map[assetType];
assetCountByUserId[type] = count;
assetCountByUserId.total += count;
}
return assetCountByUserId;
}
}

View File

@@ -228,6 +228,11 @@ export class AssetController {
return this.assetService.getAssetCountByUserId(authUser);
}
@Authenticated()
@Get('/stat/archive')
async getArchivedAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getArchivedAssetCountByUserId(authUser);
}
/**
* Get all AssetEntity belong to the user
*/

View File

@@ -28,6 +28,7 @@ export class AssetCore {
type: dto.assetType,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived ?? false,
duration: dto.duration || null,
isVisible: dto.isVisible ?? true,
livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null,

View File

@@ -32,6 +32,7 @@ const _getCreateAssetDto = (): CreateAssetDto => {
createAssetDto.fileCreatedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.fileModifiedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.isFavorite = false;
createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
@@ -51,6 +52,7 @@ const _getAsset_1 = () => {
asset_1.fileCreatedAt = '2022-06-19T23:41:36.910Z';
asset_1.updatedAt = '2022-06-19T23:41:36.910Z';
asset_1.isFavorite = false;
asset_1.isArchived = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
@@ -72,6 +74,7 @@ const _getAsset_2 = () => {
asset_2.fileCreatedAt = '2022-06-19T23:41:36.910Z';
asset_2.updatedAt = '2022-06-19T23:41:36.910Z';
asset_2.isFavorite = false;
asset_2.isArchived = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
@@ -105,6 +108,15 @@ const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
return result;
};
const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto();
result.videos = 1;
result.photos = 2;
return result;
};
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
@@ -136,6 +148,7 @@ describe('AssetService', () => {
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};
@@ -350,14 +363,16 @@ describe('AssetService', () => {
it('get asset count by user id', async () => {
const assetCount = _getAssetCountByUserId();
assetRepositoryMock.getAssetCountByUserId.mockResolvedValue(assetCount);
assetRepositoryMock.getAssetCountByUserId.mockImplementation(() =>
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
);
await expect(sut.getAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
});
const result = await sut.getAssetCountByUserId(authStub.user1);
it('get archived asset count by user id', async () => {
const assetCount = _getArchivedAssetsCountByUserId();
assetRepositoryMock.getArchivedAssetCountByUserId.mockResolvedValue(assetCount);
expect(result).toEqual(assetCount);
await expect(sut.getArchivedAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
});
describe('deleteAll', () => {

View File

@@ -466,6 +466,10 @@ export class AssetService {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
getArchivedAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
}
async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
for (const assetId of assetIds) {
// Step 1: Check if asset is part of a public shared

View File

@@ -9,6 +9,12 @@ export class AssetSearchDto {
@Transform(toBoolean)
isFavorite?: boolean;
@IsOptional()
@IsNotEmpty()
@IsBoolean()
@Transform(toBoolean)
isArchived?: boolean;
@IsOptional()
@IsNumber()
skip?: number;

View File

@@ -24,6 +24,10 @@ export class CreateAssetDto {
@IsNotEmpty()
isFavorite!: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsBoolean()
isVisible?: boolean;

View File

@@ -6,6 +6,10 @@ export class UpdateAssetDto {
@IsBoolean()
isFavorite?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })