Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): user metadata #9650

Merged
merged 6 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions e2e/src/api/specs/user.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,23 +257,6 @@ describe('/user', () => {
expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' });
});

it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });

const { status, body } = await request(app)
.put(`/user`)
.send({
id: admin.userId,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
deletedAt: '2023-01-01T00:00:00.000Z',
})
.set('Authorization', `Bearer ${admin.accessToken}`);

expect(status).toBe(200);
expect(body).toStrictEqual(before);
});

it('should update first and last name', async () => {
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });

Expand Down
2 changes: 1 addition & 1 deletion server/src/cores/user.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class UserCore {
dto.storageLabel = null;
}

return this.userRepository.update(id, dto);
return this.userRepository.update(id, { ...dto, updatedAt: new Date() });
}

async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
Expand Down
9 changes: 0 additions & 9 deletions server/src/dtos/user-profile.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { UploadFieldName } from 'src/dtos/asset.dto';
import { UserAvatarColor, UserEntity } from 'src/entities/user.entity';

export class CreateProfileImageDto {
@ApiProperty({ type: 'string', format: 'binary' })
Expand All @@ -18,11 +17,3 @@ export function mapCreateProfileImageResponse(userId: string, profileImagePath:
profileImagePath: profileImagePath,
};
}

export const getRandomAvatarColor = (user: UserEntity): UserAvatarColor => {
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,
);
return values[randomIndex] as UserAvatarColor;
};
9 changes: 5 additions & 4 deletions server/src/dtos/user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { getRandomAvatarColor } from 'src/dtos/user-profile.dto';
import { UserAvatarColor, UserEntity, UserStatus } from 'src/entities/user.entity';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { getPreferences } from 'src/utils/preferences';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';

export class CreateUserDto {
Expand Down Expand Up @@ -151,7 +152,7 @@ export const mapSimpleUser = (entity: UserEntity): UserDto => {
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: entity.avatarColor ?? getRandomAvatarColor(entity),
avatarColor: getPreferences(entity).avatar.color,
};
};

Expand All @@ -165,7 +166,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt,
oauthId: entity.oauthId,
memoriesEnabled: entity.memoriesEnabled,
memoriesEnabled: getPreferences(entity).memories.enabled,
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,
Expand Down
2 changes: 2 additions & 0 deletions server/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';

export const entities = [
Expand All @@ -44,6 +45,7 @@ export const entities = [
SystemMetadataEntity,
TagEntity,
UserEntity,
UserMetadataEntity,
SessionEntity,
LibraryEntity,
];
63 changes: 63 additions & 0 deletions server/src/entities/user-metadata.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { UserEntity } from 'src/entities/user.entity';
import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm';

@Entity('user_metadata')
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> {
@PrimaryColumn({ type: 'uuid' })
userId!: string;

@ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user!: UserEntity;

@PrimaryColumn({ type: 'varchar' })
key!: T;

@Column({ type: 'jsonb' })
value!: UserMetadata[T];
}

export enum UserAvatarColor {
PRIMARY = 'primary',
PINK = 'pink',
RED = 'red',
YELLOW = 'yellow',
BLUE = 'blue',
GREEN = 'green',
PURPLE = 'purple',
ORANGE = 'orange',
GRAY = 'gray',
AMBER = 'amber',
}

export interface UserPreferences {
memories: {
enabled: boolean;
};
avatar: {
color: UserAvatarColor;
};
}

export 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,
);

return {
memories: {
enabled: true,
},
avatar: {
color: values[randomIndex],
},
};
};

export enum UserMetadataKey {
PREFERENCES = 'preferences',
}

export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
[UserMetadataKey.PREFERENCES]: DeepPartial<UserPreferences>;
}
23 changes: 4 additions & 19 deletions server/src/entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import {
Column,
CreateDateColumn,
Expand All @@ -10,19 +11,6 @@ import {
UpdateDateColumn,
} from 'typeorm';

export enum UserAvatarColor {
PRIMARY = 'primary',
PINK = 'pink',
RED = 'red',
YELLOW = 'yellow',
BLUE = 'blue',
GREEN = 'green',
PURPLE = 'purple',
ORANGE = 'orange',
GRAY = 'gray',
AMBER = 'amber',
}

export enum UserStatus {
ACTIVE = 'active',
REMOVING = 'removing',
Expand All @@ -37,9 +25,6 @@ export class UserEntity {
@Column({ default: '' })
name!: string;

@Column({ type: 'varchar', nullable: true })
avatarColor!: UserAvatarColor | null;

@Column({ default: false })
isAdmin!: boolean;

Expand Down Expand Up @@ -73,9 +58,6 @@ export class UserEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;

@Column({ default: true })
memoriesEnabled!: boolean;

@OneToMany(() => TagEntity, (tag) => tag.user)
tags!: TagEntity[];

Expand All @@ -87,4 +69,7 @@ export class UserEntity {

@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: number;

@OneToMany(() => UserMetadataEntity, (metadata) => metadata.user)
metadata!: UserMetadataEntity[];
}
2 changes: 2 additions & 0 deletions server/src/interfaces/user.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UserMetadata } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';

export interface UserListFilter {
Expand Down Expand Up @@ -31,6 +32,7 @@ export interface IUserRepository {
getUserStats(): Promise<UserStatsQueryResponse[]>;
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
upsertMetadata<T extends keyof UserMetadata>(id: string, item: { key: T; value: UserMetadata[T] }): Promise<void>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
updateUsage(id: string, delta: number): Promise<void>;
syncUsage(id?: string): Promise<void>;
Expand Down
60 changes: 60 additions & 0 deletions server/src/migrations/1716312279245-UserMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class UserMetadata1716312279245 implements MigrationInterface {
name = 'UserMetadata1716312279245';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_metadata" ("userId" uuid NOT NULL, "key" character varying NOT NULL, "value" jsonb NOT NULL, CONSTRAINT "PK_5931462150b3438cbc83277fe5a" PRIMARY KEY ("userId", "key"))`,
);
const users = await queryRunner.query('SELECT "id", "memoriesEnabled", "avatarColor" FROM "users"');
for (const { id, memoriesEnabled, avatarColor } of users) {
const preferences: any = {};
if (!memoriesEnabled) {
preferences.memories = { enabled: false };
}

if (avatarColor) {
preferences.avatar = { color: avatarColor };
}

if (Object.keys(preferences).length === 0) {
continue;
}

await queryRunner.query('INSERT INTO "user_metadata" ("userId", "key", "value") VALUES ($1, $2, $3)', [
id,
'preferences',
preferences,
]);
}
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "memoriesEnabled"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatarColor"`);
await queryRunner.query(
`ALTER TABLE "user_metadata" ADD CONSTRAINT "FK_6afb43681a21cf7815932bc38ac" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_metadata" DROP CONSTRAINT "FK_6afb43681a21cf7815932bc38ac"`);
await queryRunner.query(`ALTER TABLE "users" ADD "avatarColor" character varying`);
await queryRunner.query(`ALTER TABLE "users" ADD "memoriesEnabled" boolean NOT NULL DEFAULT true`);
const items = await queryRunner.query(
`SELECT "userId" as "id", "value" FROM "user_metadata" WHERE "key"='preferences'`,
);
for (const { id, value } of items) {
if (!value) {
continue;
}

if (value.avatar?.color) {
await queryRunner.query(`UPDATE "users" SET "avatarColor" = $1 WHERE "id" = $2`, [value.avatar.color, id]);
}

if (value.memories?.enabled === false) {
await queryRunner.query(`UPDATE "users" SET "memoriesEnabled" = false WHERE "id" = $1`, [id]);
}
}
await queryRunner.query(`DROP TABLE "user_metadata"`);
}
}
2 changes: 0 additions & 2 deletions server/src/queries/activity.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ SELECT
"ActivityEntity"."isLiked" AS "ActivityEntity_isLiked",
"ActivityEntity__ActivityEntity_user"."id" AS "ActivityEntity__ActivityEntity_user_id",
"ActivityEntity__ActivityEntity_user"."name" AS "ActivityEntity__ActivityEntity_user_name",
"ActivityEntity__ActivityEntity_user"."avatarColor" AS "ActivityEntity__ActivityEntity_user_avatarColor",
"ActivityEntity__ActivityEntity_user"."isAdmin" AS "ActivityEntity__ActivityEntity_user_isAdmin",
"ActivityEntity__ActivityEntity_user"."email" AS "ActivityEntity__ActivityEntity_user_email",
"ActivityEntity__ActivityEntity_user"."storageLabel" AS "ActivityEntity__ActivityEntity_user_storageLabel",
Expand All @@ -23,7 +22,6 @@ SELECT
"ActivityEntity__ActivityEntity_user"."deletedAt" AS "ActivityEntity__ActivityEntity_user_deletedAt",
"ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status",
"ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt",
"ActivityEntity__ActivityEntity_user"."memoriesEnabled" AS "ActivityEntity__ActivityEntity_user_memoriesEnabled",
"ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes",
"ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes"
FROM
Expand Down
Loading
Loading