mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 09:14:58 +03:00
feat(web,server): activity (#4682)
* feat: activity * regenerate api * fix: make asset owner unable to delete comment * fix: merge * fix: tests * feat: use textarea instead of input * fix: do actions only if the album is shared * fix: placeholder opacity * fix(web): improve messages UI * fix(web): improve input message UI * pr feedback * fix: tests * pr feedback * pr feedback * pr feedback * fix permissions * regenerate api * pr feedback * pr feedback * multiple improvements on web * fix: ui colors * WIP * chore: open api * pr feedback * fix: add comment * chore: clean up * pr feedback * refactor: endpoints * chore: open api * fix: filter by type * fix: e2e * feat: e2e remove own comment * fix: web tests * remove console.log * chore: cleanup * fix: ui tweaks * pr feedback * fix web test * fix: unit tests * chore: remove unused code * revert useless changes * fix: grouping messages * fix: remove nullable on updatedAt * fix: text overflow * styling --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@@ -3,6 +3,9 @@ import { AuthUserDto } from '../auth';
|
||||
import { IAccessRepository } from '../repositories';
|
||||
|
||||
export enum Permission {
|
||||
ACTIVITY_CREATE = 'activity.create',
|
||||
ACTIVITY_DELETE = 'activity.delete',
|
||||
|
||||
// ASSET_CREATE = 'asset.create',
|
||||
ASSET_READ = 'asset.read',
|
||||
ASSET_UPDATE = 'asset.update',
|
||||
@@ -133,6 +136,20 @@ export class AccessCore {
|
||||
|
||||
private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) {
|
||||
switch (permission) {
|
||||
// uses album id
|
||||
case Permission.ACTIVITY_CREATE:
|
||||
return (
|
||||
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
// uses activity id
|
||||
case Permission.ACTIVITY_DELETE:
|
||||
return (
|
||||
(await this.repository.activity.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ASSET_READ:
|
||||
return (
|
||||
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
||||
|
||||
65
server/src/domain/activity/activity.dto.ts
Normal file
65
server/src/domain/activity/activity.dto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ActivityEntity } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from '../domain.util';
|
||||
import { UserDto, mapSimpleUser } from '../user/response-dto';
|
||||
|
||||
export enum ReactionType {
|
||||
COMMENT = 'comment',
|
||||
LIKE = 'like',
|
||||
}
|
||||
|
||||
export type MaybeDuplicate<T> = { duplicate: boolean; value: T };
|
||||
|
||||
export class ActivityResponseDto {
|
||||
id!: string;
|
||||
createdAt!: Date;
|
||||
type!: ReactionType;
|
||||
user!: UserDto;
|
||||
assetId!: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export class ActivityStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
comments!: number;
|
||||
}
|
||||
|
||||
export class ActivityDto {
|
||||
@ValidateUUID()
|
||||
albumId!: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
export class ActivitySearchDto extends ActivityDto {
|
||||
@IsEnum(ReactionType)
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
|
||||
type?: ReactionType;
|
||||
}
|
||||
|
||||
const isComment = (dto: ActivityCreateDto) => dto.type === 'comment';
|
||||
|
||||
export class ActivityCreateDto extends ActivityDto {
|
||||
@IsEnum(ReactionType)
|
||||
@ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
|
||||
type!: ReactionType;
|
||||
|
||||
@ValidateIf(isComment)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export function mapActivity(activity: ActivityEntity): ActivityResponseDto {
|
||||
return {
|
||||
id: activity.id,
|
||||
assetId: activity.assetId,
|
||||
createdAt: activity.createdAt,
|
||||
comment: activity.comment,
|
||||
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
|
||||
user: mapSimpleUser(activity.user),
|
||||
};
|
||||
}
|
||||
80
server/src/domain/activity/activity.service.ts
Normal file
80
server/src/domain/activity/activity.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ActivityEntity } from '@app/infra/entities';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AccessCore, Permission } from '../access';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IAccessRepository, IActivityRepository } from '../repositories';
|
||||
import {
|
||||
ActivityCreateDto,
|
||||
ActivityDto,
|
||||
ActivityResponseDto,
|
||||
ActivitySearchDto,
|
||||
ActivityStatisticsResponseDto,
|
||||
MaybeDuplicate,
|
||||
ReactionType,
|
||||
mapActivity,
|
||||
} from './activity.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IActivityRepository) private repository: IActivityRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
|
||||
const activities = await this.repository.search({
|
||||
albumId: dto.albumId,
|
||||
assetId: dto.assetId,
|
||||
isLiked: dto.type && dto.type === ReactionType.LIKE,
|
||||
});
|
||||
|
||||
return activities.map(mapActivity);
|
||||
}
|
||||
|
||||
async getStatistics(authUser: AuthUserDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
|
||||
return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) };
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
|
||||
await this.access.requirePermission(authUser, Permission.ACTIVITY_CREATE, dto.albumId);
|
||||
|
||||
const common = {
|
||||
userId: authUser.id,
|
||||
assetId: dto.assetId,
|
||||
albumId: dto.albumId,
|
||||
};
|
||||
|
||||
let activity: ActivityEntity | null = null;
|
||||
let duplicate = false;
|
||||
|
||||
if (dto.type === 'like') {
|
||||
delete dto.comment;
|
||||
[activity] = await this.repository.search({
|
||||
...common,
|
||||
isLiked: true,
|
||||
});
|
||||
duplicate = !!activity;
|
||||
}
|
||||
|
||||
if (!activity) {
|
||||
activity = await this.repository.create({
|
||||
...common,
|
||||
isLiked: dto.type === ReactionType.LIKE,
|
||||
comment: dto.comment,
|
||||
});
|
||||
}
|
||||
|
||||
return { duplicate, value: mapActivity(activity) };
|
||||
}
|
||||
|
||||
async delete(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
await this.access.requirePermission(authUser, Permission.ACTIVITY_DELETE, id);
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
}
|
||||
168
server/src/domain/activity/activity.spec.ts
Normal file
168
server/src/domain/activity/activity.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { authStub, IAccessRepositoryMock, newAccessRepositoryMock } from '@test';
|
||||
import { activityStub } from '@test/fixtures/activity.stub';
|
||||
import { newActivityRepositoryMock } from '@test/repositories/activity.repository.mock';
|
||||
import { IActivityRepository } from '../repositories';
|
||||
import { ReactionType } from './activity.dto';
|
||||
import { ActivityService } from './activity.service';
|
||||
|
||||
describe(ActivityService.name, () => {
|
||||
let sut: ActivityService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let activityMock: jest.Mocked<IActivityRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
activityMock = newActivityRepositoryMock();
|
||||
|
||||
sut = new ActivityService(accessMock, activityMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get all', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]);
|
||||
|
||||
expect(activityMock.search).toHaveBeenCalledWith({
|
||||
assetId: 'asset-id',
|
||||
albumId: 'album-id',
|
||||
isLiked: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by type=like', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(activityMock.search).toHaveBeenCalledWith({
|
||||
assetId: 'asset-id',
|
||||
albumId: 'album-id',
|
||||
isLiked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by type=comment', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(activityMock.search).toHaveBeenCalledWith({
|
||||
assetId: 'asset-id',
|
||||
albumId: 'album-id',
|
||||
isLiked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the comment count', async () => {
|
||||
activityMock.getStatistics.mockResolvedValue(1);
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
await expect(
|
||||
sut.getStatistics(authStub.admin, {
|
||||
assetId: 'asset-id',
|
||||
albumId: activityStub.oneComment.albumId,
|
||||
}),
|
||||
).resolves.toEqual({ comments: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addComment', () => {
|
||||
it('should require access to the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
type: ReactionType.COMMENT,
|
||||
comment: 'comment',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
type: ReactionType.COMMENT,
|
||||
comment: 'comment',
|
||||
});
|
||||
|
||||
expect(activityMock.create).toHaveBeenCalledWith({
|
||||
userId: 'admin_id',
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
comment: 'comment',
|
||||
isLiked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
activityMock.create.mockResolvedValue(activityStub.liked);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
|
||||
expect(activityMock.create).toHaveBeenCalledWith({
|
||||
userId: 'admin_id',
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
isLiked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip if like exists', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
activityMock.search.mockResolvedValue([activityStub.liked]);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
|
||||
expect(activityMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
accessMock.activity.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(activityMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the activity owner delete a comment', async () => {
|
||||
accessMock.activity.hasOwnerAccess.mockResolvedValue(true);
|
||||
await sut.delete(authStub.admin, 'activity-id');
|
||||
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
||||
});
|
||||
|
||||
it('should let the album owner delete a comment', async () => {
|
||||
accessMock.activity.hasAlbumOwnerAccess.mockResolvedValue(true);
|
||||
await sut.delete(authStub.admin, 'activity-id');
|
||||
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
2
server/src/domain/activity/index.ts
Normal file
2
server/src/domain/activity/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './activity.dto';
|
||||
export * from './activity.service';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, Provider } from '@nestjs/common';
|
||||
import { ActivityService } from './activity';
|
||||
import { AlbumService } from './album';
|
||||
import { APIKeyService } from './api-key';
|
||||
import { AssetService } from './asset';
|
||||
@@ -21,6 +22,7 @@ import { TagService } from './tag';
|
||||
import { UserService } from './user';
|
||||
|
||||
const providers: Provider[] = [
|
||||
ActivityService,
|
||||
AlbumService,
|
||||
APIKeyService,
|
||||
AssetService,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './access';
|
||||
export * from './activity';
|
||||
export * from './album';
|
||||
export * from './api-key';
|
||||
export * from './asset';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export const IAccessRepository = 'IAccessRepository';
|
||||
|
||||
export interface IAccessRepository {
|
||||
activity: {
|
||||
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
};
|
||||
asset: {
|
||||
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasAlbumAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
|
||||
11
server/src/domain/repositories/activity.repository.ts
Normal file
11
server/src/domain/repositories/activity.repository.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ActivityEntity } from '@app/infra/entities/activity.entity';
|
||||
import { ActivitySearch } from '@app/infra/repositories';
|
||||
|
||||
export const IActivityRepository = 'IActivityRepository';
|
||||
|
||||
export interface IActivityRepository {
|
||||
search(options: ActivitySearch): Promise<ActivityEntity[]>;
|
||||
create(activity: Partial<ActivityEntity>): Promise<ActivityEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
getStatistics(assetId: string | undefined, albumId: string): Promise<number>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './access.repository';
|
||||
export * from './activity.repository';
|
||||
export * from './album.repository';
|
||||
export * from './api-key.repository';
|
||||
export * from './asset.repository';
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { UserEntity } from '@app/infra/entities';
|
||||
|
||||
export class UserResponseDto {
|
||||
export class UserDto {
|
||||
id!: string;
|
||||
email!: string;
|
||||
firstName!: string;
|
||||
lastName!: string;
|
||||
email!: string;
|
||||
profileImagePath!: string;
|
||||
}
|
||||
|
||||
export class UserResponseDto extends UserDto {
|
||||
storageLabel!: string | null;
|
||||
externalPath!: string | null;
|
||||
profileImagePath!: string;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
createdAt!: Date;
|
||||
@@ -17,15 +20,21 @@ export class UserResponseDto {
|
||||
memoriesEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
export const mapSimpleUser = (entity: UserEntity): UserDto => {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
};
|
||||
};
|
||||
|
||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
return {
|
||||
...mapSimpleUser(entity),
|
||||
storageLabel: entity.storageLabel,
|
||||
externalPath: entity.externalPath,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isAdmin: entity.isAdmin,
|
||||
createdAt: entity.createdAt,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FileUploadInterceptor } from './app.interceptor';
|
||||
import { AppService } from './app.service';
|
||||
import {
|
||||
APIKeyController,
|
||||
ActivityController,
|
||||
AlbumController,
|
||||
AppController,
|
||||
AssetController,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
],
|
||||
controllers: [
|
||||
ActivityController,
|
||||
AssetController,
|
||||
AssetControllerV1,
|
||||
AppController,
|
||||
|
||||
52
server/src/immich/controllers/activity.controller.ts
Normal file
52
server/src/immich/controllers/activity.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { AuthUserDto } from '@app/domain';
|
||||
import {
|
||||
ActivityDto,
|
||||
ActivitySearchDto,
|
||||
ActivityService,
|
||||
ActivityCreateDto as CreateDto,
|
||||
ActivityResponseDto as ResponseDto,
|
||||
ActivityStatisticsResponseDto as StatsResponseDto,
|
||||
} from '@app/domain/activity';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { AuthUser, Authenticated } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
|
||||
@ApiTags('Activity')
|
||||
@Controller('activity')
|
||||
@Authenticated()
|
||||
@UseValidation()
|
||||
export class ActivityController {
|
||||
constructor(private service: ActivityService) {}
|
||||
|
||||
@Get()
|
||||
getActivities(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivitySearchDto): Promise<ResponseDto[]> {
|
||||
return this.service.getAll(authUser, dto);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
getActivityStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivityDto): Promise<StatsResponseDto> {
|
||||
return this.service.getStatistics(authUser, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createActivity(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Body() dto: CreateDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<ResponseDto> {
|
||||
const { duplicate, value } = await this.service.create(authUser, dto);
|
||||
if (duplicate) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteActivity(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(authUser, id);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './activity.controller';
|
||||
export * from './album.controller';
|
||||
export * from './api-key.controller';
|
||||
export * from './app.controller';
|
||||
|
||||
51
server/src/infra/entities/activity.entity.ts
Normal file
51
server/src/infra/entities/activity.entity.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AlbumEntity } from './album.entity';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('activity')
|
||||
@Index('IDX_activity_like', ['assetId', 'userId', 'albumId'], { unique: true, where: '("isLiked" = true)' })
|
||||
@Check(`("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`)
|
||||
export class ActivityEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column()
|
||||
albumId!: string;
|
||||
|
||||
@Column()
|
||||
userId!: string;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
assetId!: string | null;
|
||||
|
||||
@Column({ type: 'text', default: null })
|
||||
comment!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isLiked!: boolean;
|
||||
|
||||
@ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
|
||||
asset!: AssetEntity | null;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
user!: UserEntity;
|
||||
|
||||
@ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
album!: AlbumEntity;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityEntity } from './activity.entity';
|
||||
import { AlbumEntity } from './album.entity';
|
||||
import { APIKeyEntity } from './api-key.entity';
|
||||
import { AssetFaceEntity } from './asset-face.entity';
|
||||
@@ -15,6 +16,7 @@ import { TagEntity } from './tag.entity';
|
||||
import { UserTokenEntity } from './user-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
export * from './activity.entity';
|
||||
export * from './album.entity';
|
||||
export * from './api-key.entity';
|
||||
export * from './asset-face.entity';
|
||||
@@ -33,6 +35,7 @@ export * from './user-token.entity';
|
||||
export * from './user.entity';
|
||||
|
||||
export const databaseEntities = [
|
||||
ActivityEntity,
|
||||
AlbumEntity,
|
||||
APIKeyEntity,
|
||||
AssetEntity,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IAccessRepository,
|
||||
IActivityRepository,
|
||||
IAlbumRepository,
|
||||
IAssetRepository,
|
||||
IAuditRepository,
|
||||
@@ -35,6 +36,7 @@ import { bullConfig, bullQueues } from './infra.config';
|
||||
import {
|
||||
APIKeyRepository,
|
||||
AccessRepository,
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AssetRepository,
|
||||
AuditRepository,
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
} from './repositories';
|
||||
|
||||
const providers: Provider[] = [
|
||||
{ provide: IActivityRepository, useClass: ActivityRepository },
|
||||
{ provide: IAccessRepository, useClass: AccessRepository },
|
||||
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
||||
{ provide: IAssetRepository, useClass: AssetRepository },
|
||||
|
||||
22
server/src/infra/migrations/1698693294632-AddActivity.ts
Normal file
22
server/src/infra/migrations/1698693294632-AddActivity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddActivity1698693294632 implements MigrationInterface {
|
||||
name = 'AddActivity1698693294632'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "activity" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "assetId" uuid, "comment" text, "isLiked" boolean NOT NULL DEFAULT false, CONSTRAINT "CHK_2ab1e70f113f450eb40c1e3ec8" CHECK (("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)), CONSTRAINT "PK_24625a1d6b1b089c8ae206fe467" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_activity_like" ON "activity" ("assetId", "userId", "albumId") WHERE ("isLiked" = true)`);
|
||||
await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_8091ea76b12338cb4428d33d782" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_1af8519996fbfb3684b58df280b" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_1af8519996fbfb3684b58df280b"`);
|
||||
await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea"`);
|
||||
await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_8091ea76b12338cb4428d33d782"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_activity_like"`);
|
||||
await queryRunner.query(`DROP TABLE "activity"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { IAccessRepository } from '@app/domain';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
ActivityEntity,
|
||||
AlbumEntity,
|
||||
AssetEntity,
|
||||
LibraryEntity,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
|
||||
export class AccessRepository implements IAccessRepository {
|
||||
constructor(
|
||||
@InjectRepository(ActivityEntity) private activityRepository: Repository<ActivityEntity>,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
||||
@InjectRepository(LibraryEntity) private libraryRepository: Repository<LibraryEntity>,
|
||||
@@ -22,6 +24,26 @@ export class AccessRepository implements IAccessRepository {
|
||||
@InjectRepository(UserTokenEntity) private tokenRepository: Repository<UserTokenEntity>,
|
||||
) {}
|
||||
|
||||
activity = {
|
||||
hasOwnerAccess: (userId: string, activityId: string): Promise<boolean> => {
|
||||
return this.activityRepository.exist({
|
||||
where: {
|
||||
id: activityId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
hasAlbumOwnerAccess: (userId: string, activityId: string): Promise<boolean> => {
|
||||
return this.activityRepository.exist({
|
||||
where: {
|
||||
id: activityId,
|
||||
album: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
library = {
|
||||
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {
|
||||
return this.libraryRepository.exist({
|
||||
|
||||
64
server/src/infra/repositories/activity.repository.ts
Normal file
64
server/src/infra/repositories/activity.repository.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { IActivityRepository } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ActivityEntity } from '../entities/activity.entity';
|
||||
|
||||
export interface ActivitySearch {
|
||||
albumId?: string;
|
||||
assetId?: string;
|
||||
userId?: string;
|
||||
isLiked?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivityRepository implements IActivityRepository {
|
||||
constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {}
|
||||
|
||||
search(options: ActivitySearch): Promise<ActivityEntity[]> {
|
||||
const { userId, assetId, albumId, isLiked } = options;
|
||||
return this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
assetId,
|
||||
albumId,
|
||||
isLiked,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
create(entity: Partial<ActivityEntity>): Promise<ActivityEntity> {
|
||||
return this.save(entity);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
|
||||
getStatistics(assetId: string, albumId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { assetId, albumId, isLiked: false },
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async save(entity: Partial<ActivityEntity>) {
|
||||
const { id } = await this.repository.save(entity);
|
||||
return this.repository.findOneOrFail({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './access.repository';
|
||||
export * from './activity.repository';
|
||||
export * from './album.repository';
|
||||
export * from './api-key.repository';
|
||||
export * from './asset.repository';
|
||||
|
||||
Reference in New Issue
Block a user