feat(server): user metadata (#9650)

* feat(server): user metadata

* add missing method to user mock

* update migration to include cascades

* update sql files

* test: fix e2e

* chore: clean up

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen
2024-05-22 08:13:36 -04:00
committed by GitHub
parent a4887bfa7e
commit 06ce8247cc
24 changed files with 267 additions and 126 deletions

View File

@@ -4,6 +4,7 @@ import { Issuer, generators } from 'openid-client';
import { Socket } from 'socket.io';
import { AuthType } from 'src/constants';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@@ -248,8 +249,13 @@ describe('AuthService', () => {
it('should sign up the admin', async () => {
userMock.getAdmin.mockResolvedValue(null);
userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01') } as UserEntity);
await expect(sut.adminSignUp(dto)).resolves.toEqual({
userMock.create.mockResolvedValue({
...dto,
id: 'admin',
createdAt: new Date('2021-01-01'),
metadata: [] as UserMetadataEntity[],
} as UserEntity);
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
avatarColor: expect.any(String),
id: 'admin',
createdAt: new Date('2021-01-01'),

View File

@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { PartnerResponseDto } from 'src/dtos/partner.dto';
import { UserAvatarColor } from 'src/entities/user.entity';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
import { PartnerService } from 'src/services/partner.service';
@@ -23,7 +23,7 @@ const responseDto = {
deletedAt: null,
updatedAt: new Date('2021-01-01'),
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
avatarColor: UserAvatarColor.GRAY,
quotaSizeInBytes: null,
inTimeline: true,
quotaUsageInBytes: 0,

View File

@@ -138,13 +138,17 @@ describe(UserService.name, () => {
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: userStub.user1.id,
storageLabel: null,
updatedAt: expect.any(Date),
});
});
it('should omit a storage label set by non-admin users', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: userStub.user1 }, { id: userStub.user1.id, storageLabel: 'admin' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: userStub.user1.id });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: userStub.user1.id,
updatedAt: expect.any(Date),
});
});
it('user can only update its information', async () => {
@@ -174,6 +178,7 @@ describe(UserService.name, () => {
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: 'user-id',
email: 'updated@test.com',
updatedAt: expect.any(Date),
});
});
@@ -210,6 +215,7 @@ describe(UserService.name, () => {
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: 'user-id',
shouldChangePassword: true,
updatedAt: expect.any(Date),
});
});
@@ -231,7 +237,7 @@ describe(UserService.name, () => {
await sut.update(authStub.admin, dto);
expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, dto);
expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { ...dto, updatedAt: expect.any(Date) });
});
it('should not let the another user become an admin', async () => {

View File

@@ -6,6 +6,7 @@ import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@@ -16,6 +17,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
@Injectable()
export class UserService {
@@ -61,9 +63,21 @@ export class UserService {
}
async create(dto: CreateUserDto): Promise<UserResponseDto> {
const user = await this.userCore.createUser(dto);
const tempPassword = user.shouldChangePassword ? dto.password : undefined;
if (dto.notify) {
const { memoriesEnabled, notify, ...rest } = dto;
let user = await this.userCore.createUser(rest);
// TODO remove and replace with entire dto.preferences config
if (memoriesEnabled === false) {
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: { memories: { enabled: false } },
});
user = await this.findOrFail(user.id, {});
}
const tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
}
return mapUser(user);
@@ -76,7 +90,28 @@ export class UserService {
await this.userRepository.syncUsage(dto.id);
}
return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser);
// TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user);
if (dto.memoriesEnabled !== undefined) {
newPreferences.memories.enabled = dto.memoriesEnabled;
delete dto.memoriesEnabled;
}
if (dto.avatarColor) {
newPreferences.avatar.color = dto.avatarColor;
delete dto.avatarColor;
}
await this.userRepository.upsertMetadata(dto.id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, newPreferences),
});
}
const updatedUser = await this.userCore.updateUser(auth.user, dto.id, dto);
return mapUser(updatedUser);
}
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> {