mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 17:23:21 +03:00
refactor: user avatar color (#17753)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
Permission,
|
||||
SharedLinkType,
|
||||
SourceType,
|
||||
UserAvatarColor,
|
||||
UserStatus,
|
||||
} from 'src/enum';
|
||||
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
||||
@@ -122,6 +123,7 @@ export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarColor: UserAvatarColor | null;
|
||||
profileImagePath: string;
|
||||
profileChangedAt: Date;
|
||||
};
|
||||
@@ -264,7 +266,15 @@ export type AssetFace = {
|
||||
person?: Person | null;
|
||||
};
|
||||
|
||||
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
|
||||
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
|
||||
const userWithPrefixColumns = [
|
||||
'users.id',
|
||||
'users.name',
|
||||
'users.email',
|
||||
'users.avatarColor',
|
||||
'users.profileImagePath',
|
||||
'users.profileChangedAt',
|
||||
] as const;
|
||||
|
||||
export const columns = {
|
||||
asset: [
|
||||
@@ -306,7 +316,7 @@ export const columns = {
|
||||
'shared_links.password',
|
||||
],
|
||||
user: userColumns,
|
||||
userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'],
|
||||
userWithPrefix: userWithPrefixColumns,
|
||||
userAdmin: [
|
||||
...userColumns,
|
||||
'createdAt',
|
||||
|
||||
@@ -137,11 +137,6 @@ export class UserPreferencesUpdateDto {
|
||||
purchase?: PurchaseUpdate;
|
||||
}
|
||||
|
||||
class AvatarResponse {
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
color!: UserAvatarColor;
|
||||
}
|
||||
|
||||
class RatingsResponse {
|
||||
enabled: boolean = false;
|
||||
}
|
||||
@@ -195,7 +190,6 @@ export class UserPreferencesResponseDto implements UserPreferences {
|
||||
ratings!: RatingsResponse;
|
||||
sharedLinks!: SharedLinksResponse;
|
||||
tags!: TagsResponse;
|
||||
avatar!: AvatarResponse;
|
||||
emailNotifications!: EmailNotificationsResponse;
|
||||
download!: DownloadResponse;
|
||||
purchase!: PurchaseResponse;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
import { User, UserAdmin } from 'src/database';
|
||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
|
||||
|
||||
export class UserUpdateMeDto {
|
||||
@@ -23,6 +22,11 @@ export class UserUpdateMeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsEnum(UserAvatarColor)
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor?: UserAvatarColor | null;
|
||||
}
|
||||
|
||||
export class UserResponseDto {
|
||||
@@ -41,13 +45,21 @@ export class UserLicense {
|
||||
activatedAt!: Date;
|
||||
}
|
||||
|
||||
const emailToAvatarColor = (email: string): UserAvatarColor => {
|
||||
const values = Object.values(UserAvatarColor);
|
||||
const randomIndex = Math.floor(
|
||||
[...email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
|
||||
);
|
||||
return values[randomIndex];
|
||||
};
|
||||
|
||||
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color,
|
||||
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
||||
profileChangedAt: entity.profileChangedAt,
|
||||
};
|
||||
};
|
||||
@@ -69,6 +81,11 @@ export class UserAdminCreateDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsEnum(UserAvatarColor)
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor?: UserAvatarColor | null;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
@@ -104,6 +121,11 @@ export class UserAdminUpdateDto {
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsEnum(UserAvatarColor)
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor?: UserAvatarColor | null;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
|
||||
@@ -13,6 +13,7 @@ from
|
||||
"users"."id",
|
||||
"users"."name",
|
||||
"users"."email",
|
||||
"users"."avatarColor",
|
||||
"users"."profileImagePath",
|
||||
"users"."profileChangedAt"
|
||||
from
|
||||
@@ -44,6 +45,7 @@ returning
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
|
||||
@@ -12,6 +12,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -36,6 +37,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -100,6 +102,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -124,6 +127,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -191,6 +195,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -215,6 +220,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -269,6 +275,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -292,6 +299,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -353,6 +361,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
|
||||
@@ -12,6 +12,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -29,6 +30,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -61,6 +63,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -78,6 +81,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -112,6 +116,7 @@ returning
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -129,6 +134,7 @@ returning
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -156,6 +162,7 @@ returning
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
@@ -173,6 +180,7 @@ returning
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
|
||||
@@ -5,6 +5,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
@@ -43,6 +44,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
@@ -90,6 +92,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
@@ -128,6 +131,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
@@ -152,6 +156,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
@@ -198,6 +203,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
@@ -235,6 +241,7 @@ select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"createdAt",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "users" ADD "avatarColor" character varying;`.execute(db);
|
||||
await sql`
|
||||
UPDATE "users"
|
||||
SET "avatarColor" = "user_metadata"."value"->'avatar'->>'color'
|
||||
FROM "user_metadata"
|
||||
WHERE "users"."id" = "user_metadata"."userId" AND "user_metadata"."key" = 'preferences';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "users" DROP COLUMN "avatarColor";`.execute(db);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ColumnType } from 'kysely';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserStatus } from 'src/enum';
|
||||
import { UserAvatarColor, UserStatus } from 'src/enum';
|
||||
import { users_delete_audit } from 'src/schema/functions';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
@@ -49,6 +49,9 @@ export class UserTable {
|
||||
@Column({ type: 'boolean', default: true })
|
||||
shouldChangePassword!: Generated<boolean>;
|
||||
|
||||
@Column({ default: null })
|
||||
avatarColor!: UserAvatarColor | null;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Timestamp | null;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export class DownloadService extends BaseService {
|
||||
|
||||
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
|
||||
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
||||
const preferences = getPreferences(auth.user.email, metadata);
|
||||
const preferences = getPreferences(metadata);
|
||||
const motionIds = new Set<string>();
|
||||
const archives: DownloadArchiveInfo[] = [];
|
||||
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
|
||||
|
||||
@@ -271,7 +271,7 @@ export class NotificationService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { emailNotifications } = getPreferences(recipient.email, recipient.metadata);
|
||||
const { emailNotifications } = getPreferences(recipient.metadata);
|
||||
|
||||
if (!emailNotifications.enabled || !emailNotifications.albumInvite) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -333,7 +333,7 @@ export class NotificationService extends BaseService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { emailNotifications } = getPreferences(user.email, user.metadata);
|
||||
const { emailNotifications } = getPreferences(user.metadata);
|
||||
|
||||
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
|
||||
continue;
|
||||
|
||||
@@ -106,21 +106,19 @@ export class UserAdminService extends BaseService {
|
||||
}
|
||||
|
||||
async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> {
|
||||
const { email } = await this.findOrFail(id, { withDeleted: true });
|
||||
await this.findOrFail(id, { withDeleted: true });
|
||||
const metadata = await this.userRepository.getMetadata(id);
|
||||
const preferences = getPreferences(email, metadata);
|
||||
return mapPreferences(preferences);
|
||||
return mapPreferences(getPreferences(metadata));
|
||||
}
|
||||
|
||||
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
|
||||
const { email } = await this.findOrFail(id, { withDeleted: false });
|
||||
await this.findOrFail(id, { withDeleted: false });
|
||||
const metadata = await this.userRepository.getMetadata(id);
|
||||
const preferences = getPreferences(email, metadata);
|
||||
const newPreferences = mergePreferences(preferences, dto);
|
||||
const newPreferences = mergePreferences(getPreferences(metadata), dto);
|
||||
|
||||
await this.userRepository.upsertMetadata(id, {
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
value: getPreferencesPartial({ email }, newPreferences),
|
||||
value: getPreferencesPartial(newPreferences),
|
||||
});
|
||||
|
||||
return mapPreferences(newPreferences);
|
||||
|
||||
@@ -53,6 +53,7 @@ export class UserService extends BaseService {
|
||||
const update: Updateable<UserTable> = {
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
avatarColor: dto.avatarColor,
|
||||
};
|
||||
|
||||
if (dto.password) {
|
||||
@@ -68,18 +69,16 @@ export class UserService extends BaseService {
|
||||
|
||||
async getMyPreferences(auth: AuthDto): Promise<UserPreferencesResponseDto> {
|
||||
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
||||
const preferences = getPreferences(auth.user.email, metadata);
|
||||
return mapPreferences(preferences);
|
||||
return mapPreferences(getPreferences(metadata));
|
||||
}
|
||||
|
||||
async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) {
|
||||
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
||||
const current = getPreferences(auth.user.email, metadata);
|
||||
const updated = mergePreferences(current, dto);
|
||||
const updated = mergePreferences(getPreferences(metadata), dto);
|
||||
|
||||
await this.userRepository.upsertMetadata(auth.user.id, {
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
value: getPreferencesPartial(auth.user, updated),
|
||||
value: getPreferencesPartial(updated),
|
||||
});
|
||||
|
||||
return mapPreferences(updated);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
SyncEntityType,
|
||||
SystemMetadataKey,
|
||||
TranscodeTarget,
|
||||
UserAvatarColor,
|
||||
UserMetadataKey,
|
||||
VideoCodec,
|
||||
} from 'src/enum';
|
||||
@@ -486,9 +485,6 @@ export interface UserPreferences {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
avatar: {
|
||||
color: UserAvatarColor;
|
||||
};
|
||||
emailNotifications: {
|
||||
enabled: boolean;
|
||||
albumInvite: boolean;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import { UserAvatarColor, UserMetadataKey } from 'src/enum';
|
||||
import { UserMetadataKey } from 'src/enum';
|
||||
import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { getKeysDeep } from 'src/utils/misc';
|
||||
|
||||
const getDefaultPreferences = (user: { email: string }): UserPreferences => {
|
||||
const values = Object.values(UserAvatarColor);
|
||||
const randomIndex = Math.floor(
|
||||
[...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
|
||||
);
|
||||
|
||||
const getDefaultPreferences = (): UserPreferences => {
|
||||
return {
|
||||
folders: {
|
||||
enabled: false,
|
||||
@@ -34,9 +29,6 @@ const getDefaultPreferences = (user: { email: string }): UserPreferences => {
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
avatar: {
|
||||
color: values[randomIndex],
|
||||
},
|
||||
emailNotifications: {
|
||||
enabled: true,
|
||||
albumInvite: true,
|
||||
@@ -53,8 +45,8 @@ const getDefaultPreferences = (user: { email: string }): UserPreferences => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => {
|
||||
const preferences = getDefaultPreferences({ email });
|
||||
export const getPreferences = (metadata: UserMetadataItem[]): UserPreferences => {
|
||||
const preferences = getDefaultPreferences();
|
||||
const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES);
|
||||
const partial = item?.value || {};
|
||||
for (const property of getKeysDeep(partial)) {
|
||||
@@ -64,8 +56,8 @@ export const getPreferences = (email: string, metadata: UserMetadataItem[]): Use
|
||||
return preferences;
|
||||
};
|
||||
|
||||
export const getPreferencesPartial = (user: { email: string }, newPreferences: UserPreferences) => {
|
||||
const defaultPreferences = getDefaultPreferences(user);
|
||||
export const getPreferencesPartial = (newPreferences: UserPreferences) => {
|
||||
const defaultPreferences = getDefaultPreferences();
|
||||
const partial: DeepPartial<UserPreferences> = {};
|
||||
for (const property of getKeysDeep(defaultPreferences)) {
|
||||
const newValue = _.get(newPreferences, property);
|
||||
|
||||
Reference in New Issue
Block a user