refactor: user avatar color (#17753)

This commit is contained in:
Jason Rasmussen
2025-04-28 09:54:51 -04:00
committed by GitHub
parent 460d594791
commit ad272333db
30 changed files with 200 additions and 220 deletions

View File

@@ -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',

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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: [] };

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);