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:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './activity.dto';
|
||||
export * from './activity.service';
|
||||
Reference in New Issue
Block a user