diff --git a/migrations/20260527141812-add-photos-bucket-to-users.js b/migrations/20260527141812-add-photos-bucket-to-users.js new file mode 100644 index 000000000..71ff078be --- /dev/null +++ b/migrations/20260527141812-add-photos-bucket-to-users.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('users', 'photos_bucket', { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn('users', 'photos_bucket'); + }, +}; diff --git a/migrations/20260527141826-recreate-index-folders-bucket.js b/migrations/20260527141826-recreate-index-folders-bucket.js new file mode 100644 index 000000000..550a67bdc --- /dev/null +++ b/migrations/20260527141826-recreate-index-folders-bucket.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS bucket_index ON folders (bucket) WHERE deleted = false AND removed = false AND parent_id IS NULL`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `DROP INDEX CONCURRENTLY IF EXISTS bucket_index`, + ); + }, +}; diff --git a/src/modules/auth/conditional-captcha.guard.spec.ts b/src/modules/auth/conditional-captcha.guard.spec.ts index 8a06171ea..3d7b71405 100644 --- a/src/modules/auth/conditional-captcha.guard.spec.ts +++ b/src/modules/auth/conditional-captcha.guard.spec.ts @@ -197,10 +197,5 @@ const createMockExecutionContext = (request: Partial): ExecutionContext => }), }) as unknown as ExecutionContext; -const buildUserWithErrorLoginCount = (count: number) => ({ - ...newUser(), - errorLoginCount: count, - isGuestOnSharedWorkspace: () => false, - toJSON: undefined, - hasBackupsEnabled: () => false, -}); +const buildUserWithErrorLoginCount = (count: number) => + newUser({ attributes: { errorLoginCount: count } }); diff --git a/src/modules/backups/backup.module.ts b/src/modules/backups/backup.module.ts index 1c92def33..1a4ee0c44 100644 --- a/src/modules/backups/backup.module.ts +++ b/src/modules/backups/backup.module.ts @@ -19,6 +19,7 @@ import { UserNotificationTokensModel } from '../user/user-notification-tokens.mo import { ShareModule } from '../share/share.module'; import { ThumbnailModule } from '../thumbnail/thumbnail.module'; import { ThumbnailModel } from '../thumbnail/thumbnail.model'; +import { PhotosController } from './photos/photos.controller'; @Module({ imports: [ @@ -40,7 +41,7 @@ import { ThumbnailModel } from '../thumbnail/thumbnail.model'; BridgeModule, NotificationModule, ], - controllers: [BackupController], + controllers: [BackupController, PhotosController], providers: [ SequelizeBackupRepository, BackupUseCase, diff --git a/src/modules/backups/backup.usecase.spec.ts b/src/modules/backups/backup.usecase.spec.ts index be1e86328..e5c0f8c31 100644 --- a/src/modules/backups/backup.usecase.spec.ts +++ b/src/modules/backups/backup.usecase.spec.ts @@ -1,4 +1,5 @@ import { newDevice, newFolder, newUser } from './../../../test/fixtures'; +import { v4 } from 'uuid'; import { Test, type TestingModule } from '@nestjs/testing'; import { createMock } from '@golevelup/ts-jest'; import { BackupUseCase } from './backup.usecase'; @@ -953,6 +954,265 @@ describe('BackupUseCase', () => { }); }); + describe('activatePhotos', () => { + it('When photos bucket already exists, then it should return it', async () => { + const existingBucket = v4(); + const user = newUser({ attributes: { photosBucket: existingBucket } }); + const result = await backupUseCase.activatePhotos(user); + expect(result).toEqual({ photosBucket: existingBucket }); + }); + + it('When photos bucket does not exist, then it should create one', async () => { + const newBucket = v4(); + const user = newUser({ attributes: { photosBucket: null } }); + jest + .spyOn(bridgeService, 'createBucket') + .mockResolvedValue({ id: newBucket } as any); + + const result = await backupUseCase.activatePhotos(user); + expect(result).toEqual({ photosBucket: newBucket }); + }); + }); + + describe('createPhotoDeviceAsFolder', () => { + const userWithPhotos = newUser({ + attributes: { photosBucket: v4() }, + }); + + it('When folder with same name exists, then it should throw ConflictException', async () => { + jest.spyOn(folderRepository, 'findOne').mockResolvedValue(newFolder()); + + await expect( + backupUseCase.createPhotoDeviceAsFolder(userWithPhotos, 'My Phone'), + ).rejects.toThrow(ConflictException); + }); + + it('When no folder with same name exists, then it should create the folder', async () => { + const mockFolder = newFolder(); + jest.spyOn(folderRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(folderRepository, 'createFolder') + .mockResolvedValue(mockFolder); + jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true); + + const result = await backupUseCase.createPhotoDeviceAsFolder( + userWithPhotos, + 'My Phone', + ); + expect(result).toEqual(newBackupFolder(mockFolder)); + }); + + it('When user has no photos bucket, then it should activate photos first', async () => { + const userWithoutPhotos = newUser({ attributes: { photosBucket: null } }); + const mockFolder = newFolder(); + + jest + .spyOn(backupUseCase, 'activatePhotos') + .mockResolvedValue({ photosBucket: v4() }); + jest.spyOn(folderRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(folderRepository, 'createFolder') + .mockResolvedValue(mockFolder); + jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true); + + await backupUseCase.createPhotoDeviceAsFolder(userWithoutPhotos, 'Phone'); + + expect(backupUseCase.activatePhotos).toHaveBeenCalledWith( + userWithoutPhotos, + ); + }); + }); + + describe('getPhotoDevicesAsFolder', () => { + const userWithPhotos = newUser({ + attributes: { photosBucket: v4() }, + }); + + it('When photos are not activated, then it should throw BadRequestException', async () => { + const userWithoutPhotos = newUser({ attributes: { photosBucket: null } }); + + await expect( + backupUseCase.getPhotoDevicesAsFolder(userWithoutPhotos), + ).rejects.toThrow(BadRequestException); + }); + + it('When photos are activated, then it should return all photo devices as folders', async () => { + const mockFolder = newFolder(); + jest + .spyOn(folderUseCases, 'getFoldersByUserId') + .mockResolvedValue([mockFolder]); + jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true); + + const result = + await backupUseCase.getPhotoDevicesAsFolder(userWithPhotos); + + expect(result[0]).toEqual({ + ...newBackupFolder(mockFolder), + plainName: mockFolder.plainName, + }); + }); + + it('When folder has no plainName, then it should decrypt using bucket', async () => { + const mockFolder = newFolder({ attributes: { plainName: null } }); + jest + .spyOn(folderUseCases, 'getFoldersByUserId') + .mockResolvedValue([mockFolder]); + jest + .spyOn(cryptoService, 'decryptName') + .mockReturnValueOnce('Decrypted Phone'); + + const result = + await backupUseCase.getPhotoDevicesAsFolder(userWithPhotos); + + expect(cryptoService.decryptName).toHaveBeenCalledWith( + mockFolder.name, + mockFolder.bucket, + ); + expect(result[0].plainName).toBe('Decrypted Phone'); + }); + }); + + describe('getPhotoDeviceAsFolder', () => { + const userWithPhotos = newUser({ + attributes: { photosBucket: v4() }, + }); + + it('When folder does not exist, then it should throw NotFoundException', async () => { + jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null); + + await expect( + backupUseCase.getPhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'), + ).rejects.toThrow(NotFoundException); + }); + + it('When folder is not in the photos bucket, then it should throw', async () => { + const mockFolder = newFolder({ attributes: { bucket: v4() } }); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValue(mockFolder); + + await expect( + backupUseCase.getPhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'), + ).rejects.toThrow(BadRequestException); + }); + + it('When folder exists and is in the photos bucket, then it should return it', async () => { + const mockFolder = newFolder({ + attributes: { bucket: userWithPhotos.photosBucket }, + }); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValue(mockFolder); + jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true); + + const result = await backupUseCase.getPhotoDeviceAsFolder( + userWithPhotos, + 'folder-uuid', + ); + expect(result).toEqual(newBackupFolder(mockFolder)); + }); + }); + + describe('deletePhotoDeviceAsFolder', () => { + const userWithPhotos = newUser({ + attributes: { photosBucket: v4() }, + }); + + it('When folder does not exist, then it should throw NotFoundException', async () => { + jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null); + + await expect( + backupUseCase.deletePhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'), + ).rejects.toThrow(NotFoundException); + }); + + it('When folder is not in the photos bucket, then it should throw BadRequestException', async () => { + const mockFolder = newFolder({ attributes: { bucket: 'other-bucket' } }); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValue(mockFolder); + + await expect( + backupUseCase.deletePhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'), + ).rejects.toThrow(BadRequestException); + }); + + it('When folder is in the photos bucket, then it should delete it', async () => { + const mockFolder = newFolder({ + attributes: { bucket: userWithPhotos.photosBucket }, + }); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValue(mockFolder); + jest.spyOn(folderUseCases, 'deleteByUser').mockResolvedValue(undefined); + + await backupUseCase.deletePhotoDeviceAsFolder( + userWithPhotos, + 'folder-uuid', + ); + + expect(folderUseCases.deleteByUser).toHaveBeenCalledWith(userWithPhotos, [ + mockFolder, + ]); + }); + }); + + describe('updatePhotoDeviceAsFolder', () => { + const userWithPhotos = newUser({ + attributes: { photosBucket: v4() }, + }); + + it('When folder does not exist, then it should throw NotFoundException', async () => { + jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null); + + await expect( + backupUseCase.updatePhotoDeviceAsFolder( + userWithPhotos, + 'folder-uuid', + 'New Name', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('When folder is not in the photos bucket, then it should throw', async () => { + const mockFolder = newFolder({ attributes: { bucket: v4() } }); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValue(mockFolder); + + await expect( + backupUseCase.updatePhotoDeviceAsFolder( + userWithPhotos, + 'folder-uuid', + 'New Name', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('When folder exists and is in the photos bucket, then it should update and return it', async () => { + const mockFolder = newFolder({ + attributes: { bucket: userWithPhotos.photosBucket }, + }); + const updatedFolder = newFolder({ + attributes: { ...mockFolder, plainName: 'New Name' }, + }); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValue(mockFolder); + jest + .spyOn(folderUseCases, 'updateByFolderIdAndForceUpdatedAt') + .mockResolvedValue(updatedFolder); + jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true); + + const result = await backupUseCase.updatePhotoDeviceAsFolder( + userWithPhotos, + 'folder-uuid', + 'New Name', + ); + expect(result).toEqual(newBackupFolder(updatedFolder)); + }); + }); + describe('updateDeviceAndFolderName', () => { const updateDeviceDto = { name: 'New Device Name' }; diff --git a/src/modules/backups/backup.usecase.ts b/src/modules/backups/backup.usecase.ts index f10635d22..c200af815 100644 --- a/src/modules/backups/backup.usecase.ts +++ b/src/modules/backups/backup.usecase.ts @@ -442,6 +442,124 @@ export class BackupUseCase { }; } + async activatePhotos(user: User) { + const { email, userId, photosBucket } = user; + if (photosBucket) { + return { photosBucket }; + } + const bucket = await this.networkService.createBucket(email, userId); + await this.userRepository.updateByUuid(user.uuid, { + photosBucket: bucket.id, + }); + return { photosBucket: bucket.id }; + } + + async createPhotoDeviceAsFolder(user: User, deviceName: string) { + let bucket = user.photosBucket; + if (!bucket) { + const { photosBucket } = await this.activatePhotos(user); + bucket = photosBucket; + } + + const folder = await this.folderRepository.findOne({ + bucket, + plainName: deviceName, + deleted: false, + removed: false, + userId: user.id, + }); + + if (folder) { + throw new ConflictException('Folder with the same name already exists'); + } + + const createdFolder = await this.folderRepository.createFolder(user.id, { + plainName: deviceName, + bucket, + }); + + return this.addFolderAsDeviceProperties(user, createdFolder); + } + + async getPhotoDevicesAsFolder(user: User) { + this.verifyUserHasPhotosEnabled(user); + + const folders = await this.folderUsecases.getFoldersByUserId(user.id, { + bucket: user.photosBucket, + removed: false, + deleted: false, + }); + + return Promise.all( + folders.map(async (folder) => { + const decryptedFolder = this.decryptBackupFolderName(folder); + return { + ...(await this.addFolderAsDeviceProperties(user, decryptedFolder)), + plainName: decryptedFolder.plainName, + }; + }), + ); + } + + async getPhotoDeviceAsFolder(user: User, uuid: FolderAttributes['uuid']) { + const folder = await this.folderUsecases.getFolderByUuid(uuid, user); + if (!folder) { + throw new NotFoundException('Folder not found'); + } + + this.verifyFolderIsPhotoDevice(user, folder); + + return this.addFolderAsDeviceProperties( + user, + this.decryptBackupFolderName(folder), + ); + } + + async deletePhotoDeviceAsFolder(user: User, uuid: FolderAttributes['uuid']) { + const folder = await this.folderUsecases.getFolderByUuid(uuid, user); + if (!folder) { + throw new NotFoundException('Folder not found'); + } + + this.verifyFolderIsPhotoDevice(user, folder); + + await this.folderUsecases.deleteByUser(user, [folder]); + } + + async updatePhotoDeviceAsFolder( + user: User, + uuid: FolderAttributes['uuid'], + deviceName: string, + ) { + const folder = await this.folderUsecases.getFolderByUuid(uuid, user); + if (!folder) { + throw new NotFoundException('Folder not found'); + } + + this.verifyFolderIsPhotoDevice(user, folder); + + const updatedFolder = + await this.folderUsecases.updateByFolderIdAndForceUpdatedAt(folder, { + plainName: deviceName, + }); + + return this.addFolderAsDeviceProperties(user, updatedFolder); + } + + private verifyFolderIsPhotoDevice(user: User, folder: Folder) { + if (folder.bucket !== user.photosBucket) { + throw new BadRequestException( + `${folder.uuid} is not a valid photos device`, + ); + } + } + + private verifyUserHasPhotosEnabled(user: User) { + if (!user.hasPhotosEnabled()) { + throw new BadRequestException('Photos is not enabled for this user'); + } + } + private verifyUserHasBackupsEnabled(user: User) { if (!user.hasBackupsEnabled()) { throw new BadRequestException('Backups is not enabled for this user'); diff --git a/src/modules/backups/photos/photos.controller.spec.ts b/src/modules/backups/photos/photos.controller.spec.ts new file mode 100644 index 000000000..8f4ea7cd8 --- /dev/null +++ b/src/modules/backups/photos/photos.controller.spec.ts @@ -0,0 +1,109 @@ +import { newUser } from '../../../../test/fixtures'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { PhotosController } from './photos.controller'; +import { BackupUseCase } from '../backup.usecase'; +import { v4 } from 'uuid'; + +describe('PhotosController', () => { + let controller: PhotosController; + let backupUseCase: BackupUseCase; + + const user = newUser(); + const uuid = v4(); + const deviceFolder = { uuid, plainName: 'My Phone' } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PhotosController], + providers: [BackupUseCase], + }) + .useMocker(() => createMock()) + .compile(); + + controller = module.get(PhotosController); + backupUseCase = module.get(BackupUseCase); + }); + + describe('createPhotoDeviceAsFolder', () => { + it('When createPhotoDeviceAsFolder is called, then it should return the created device folder', async () => { + jest + .spyOn(backupUseCase, 'createPhotoDeviceAsFolder') + .mockResolvedValue(deviceFolder); + + const result = await controller.createPhotoDeviceAsFolder(user, { + deviceName: 'My Phone', + }); + + expect(backupUseCase.createPhotoDeviceAsFolder).toHaveBeenCalledWith( + user, + 'My Phone', + ); + expect(result).toEqual(deviceFolder); + }); + }); + + describe('getPhotoDevicesAsFolder', () => { + it('When getPhotoDevicesAsFolder is called, then it should return all photo devices', async () => { + jest + .spyOn(backupUseCase, 'getPhotoDevicesAsFolder') + .mockResolvedValue([deviceFolder]); + + const result = await controller.getPhotoDevicesAsFolder(user); + + expect(backupUseCase.getPhotoDevicesAsFolder).toHaveBeenCalledWith(user); + expect(result).toEqual([deviceFolder]); + }); + }); + + describe('getPhotoDeviceAsFolder', () => { + it('When getPhotoDeviceAsFolder is called with uuid, then it should return the matching device', async () => { + jest + .spyOn(backupUseCase, 'getPhotoDeviceAsFolder') + .mockResolvedValue(deviceFolder); + + const result = await controller.getPhotoDeviceAsFolder(user, uuid); + + expect(backupUseCase.getPhotoDeviceAsFolder).toHaveBeenCalledWith( + user, + uuid, + ); + expect(result).toEqual(deviceFolder); + }); + }); + + describe('deletePhotoDeviceAsFolder', () => { + it('When deletePhotoDeviceAsFolder is called, then it should delete the device folder', async () => { + jest + .spyOn(backupUseCase, 'deletePhotoDeviceAsFolder') + .mockResolvedValue(undefined); + + await controller.deletePhotoDeviceAsFolder(user, uuid); + + expect(backupUseCase.deletePhotoDeviceAsFolder).toHaveBeenCalledWith( + user, + uuid, + ); + }); + }); + + describe('updatePhotoDeviceAsFolder', () => { + it('When updatePhotoDeviceAsFolder is called, then it should return the updated device folder', async () => { + const updated = { ...deviceFolder, plainName: 'New Name' } as any; + jest + .spyOn(backupUseCase, 'updatePhotoDeviceAsFolder') + .mockResolvedValue(updated); + + const result = await controller.updatePhotoDeviceAsFolder(user, uuid, { + deviceName: 'New Name', + }); + + expect(backupUseCase.updatePhotoDeviceAsFolder).toHaveBeenCalledWith( + user, + uuid, + 'New Name', + ); + expect(result).toEqual(updated); + }); + }); +}); diff --git a/src/modules/backups/photos/photos.controller.ts b/src/modules/backups/photos/photos.controller.ts new file mode 100644 index 000000000..066a7a090 --- /dev/null +++ b/src/modules/backups/photos/photos.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { User as UserDecorator } from '../../auth/decorators/user.decorator'; +import { User } from '../../user/user.domain'; +import { BackupUseCase } from '../backup.usecase'; +import { CreateDeviceAsFolderDto } from '../dto/create-device-as-folder.dto'; +import { ValidateUUIDPipe } from '../../../common/pipes/validate-uuid.pipe'; +import { DeviceAsFolder } from '../dto/responses/device-as-folder.dto'; + +@ApiTags('Photos') +@Controller('photos') +export class PhotosController { + constructor(private readonly backupUseCases: BackupUseCase) {} + + @Post('/devices') + @ApiOperation({ summary: 'Create a photo device as folder' }) + @ApiOkResponse({ type: DeviceAsFolder }) + @ApiBearerAuth() + async createPhotoDeviceAsFolder( + @UserDecorator() user: User, + @Body() body: CreateDeviceAsFolderDto, + ): Promise { + return this.backupUseCases.createPhotoDeviceAsFolder(user, body.deviceName); + } + + @Get('/devices') + @ApiOperation({ summary: 'Get all photo devices as folder' }) + @ApiOkResponse({ type: DeviceAsFolder, isArray: true }) + @ApiBearerAuth() + async getPhotoDevicesAsFolder( + @UserDecorator() user: User, + ): Promise { + return this.backupUseCases.getPhotoDevicesAsFolder(user); + } + + @Get('/devices/:uuid') + @ApiOperation({ summary: 'Get photo device as folder by uuid' }) + @ApiOkResponse({ type: DeviceAsFolder }) + @ApiBearerAuth() + async getPhotoDeviceAsFolder( + @UserDecorator() user: User, + @Param('uuid', ValidateUUIDPipe) uuid: string, + ): Promise { + return this.backupUseCases.getPhotoDeviceAsFolder(user, uuid); + } + + @Delete('/devices/:uuid') + @ApiOperation({ summary: 'Delete photo device as folder by uuid' }) + @ApiBearerAuth() + async deletePhotoDeviceAsFolder( + @UserDecorator() user: User, + @Param('uuid', ValidateUUIDPipe) uuid: string, + ) { + return this.backupUseCases.deletePhotoDeviceAsFolder(user, uuid); + } + + @Patch('/devices/:uuid') + @ApiOperation({ summary: 'Update photo device as folder by uuid' }) + @ApiOkResponse({ type: DeviceAsFolder }) + @ApiBearerAuth() + async updatePhotoDeviceAsFolder( + @UserDecorator() user: User, + @Param('uuid', ValidateUUIDPipe) uuid: string, + @Body() body: CreateDeviceAsFolderDto, + ): Promise { + return this.backupUseCases.updatePhotoDeviceAsFolder( + user, + uuid, + body.deviceName, + ); + } +} diff --git a/src/modules/user/dto/user-to-json.dto.ts b/src/modules/user/dto/user-to-json.dto.ts index 4fbb2272b..9974e66cf 100644 --- a/src/modules/user/dto/user-to-json.dto.ts +++ b/src/modules/user/dto/user-to-json.dto.ts @@ -19,6 +19,7 @@ export interface UserToJsonDto { welcomePack: boolean; registerCompleted: boolean; backupsBucket: string; + photosBucket: string; sharedWorkspace: boolean; avatar: string; lastPasswordChangedAt?: Date; diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 7cf249341..97bc992f6 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -23,6 +23,7 @@ export interface UserAttributes { welcomePack: boolean; registerCompleted: boolean; backupsBucket: string; + photosBucket?: string; sharedWorkspace: boolean; avatar: string; lastPasswordChangedAt?: Date; diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index 8edf686a9..ed94a382f 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -24,6 +24,7 @@ export class User implements UserAttributes { welcomePack: boolean; registerCompleted: boolean; backupsBucket: string; + photosBucket: string; sharedWorkspace: boolean; avatar: string; lastPasswordChangedAt: Date; @@ -56,6 +57,7 @@ export class User implements UserAttributes { welcomePack, registerCompleted, backupsBucket, + photosBucket, sharedWorkspace, avatar, lastPasswordChangedAt, @@ -87,6 +89,7 @@ export class User implements UserAttributes { this.welcomePack = welcomePack; this.registerCompleted = registerCompleted; this.backupsBucket = backupsBucket; + this.photosBucket = photosBucket; this.sharedWorkspace = sharedWorkspace; this.avatar = avatar; this.lastPasswordChangedAt = lastPasswordChangedAt; @@ -108,6 +111,10 @@ export class User implements UserAttributes { return !!this.backupsBucket; } + hasPhotosEnabled(): boolean { + return !!this.photosBucket; + } + toJSON(): UserToJsonDto { return { id: this.id, @@ -129,6 +136,7 @@ export class User implements UserAttributes { welcomePack: this.welcomePack, registerCompleted: this.registerCompleted, backupsBucket: this.backupsBucket, + photosBucket: this.photosBucket, sharedWorkspace: this.sharedWorkspace, avatar: this.avatar, lastPasswordChangedAt: this.lastPasswordChangedAt, diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 20be96fc4..5d07b4da0 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -111,6 +111,10 @@ export class UserModel extends Model implements UserAttributes { @Column backupsBucket: string; + @AllowNull + @Column + photosBucket: string; + @Default(false) @Column sharedWorkspace: boolean;