Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
51 changes: 22 additions & 29 deletions prisma/seed/modules/asso.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,23 @@ export default function assoSeed(prisma: PrismaClient) {
for (let i = 0; i < fakerRounds; i++) {
const date: Date = faker.date.past();
const name = faker.company.name();
assos.push(
prisma.asso.create({
assos.push(async () => {
const { id: userId } = await prisma.user.create({
data: {
login: name,
firstName: '',
lastName: '',
userType: UserType.ASSOCIATION,
socialNetwork: { create: {} },
mailsPhones: { create: {} },
rgpd: { create: {} },
preference: { create: {} },
infos: { create: {} },
privacy: { create: {} },
},
select: { id: true },
});
return prisma.asso.create({
data: {
name,
mail: faker.internet.email(),
Expand All @@ -23,18 +38,7 @@ export default function assoSeed(prisma: PrismaClient) {
isPublic: true,
preset: 'AVATAR',
uploader: {
create: {
login: name,
firstName: '',
lastName: '',
userType: UserType.ASSOCIATION,
socialNetwork: { create: {} },
mailsPhones: { create: {} },
rgpd: { create: {} },
preference: { create: {} },
infos: { create: {} },
privacy: { create: {} },
},
connect: { id: userId },
},
},
},
Expand All @@ -53,22 +57,11 @@ export default function assoSeed(prisma: PrismaClient) {
},
},
assoAccount: {
create: {
login: name,
firstName: '',
lastName: '',
userType: UserType.ASSOCIATION,
socialNetwork: { create: {} },
mailsPhones: { create: {} },
rgpd: { create: {} },
preference: { create: {} },
infos: { create: {} },
privacy: { create: {} },
},
connect: { id: userId },
},
},
}),
);
});
});
}
return Promise.all(assos);
return Promise.all(assos.map((assoFn) => assoFn()));
}
38 changes: 17 additions & 21 deletions src/assos/assos.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,38 +97,34 @@ export class AssosService {
});
if (update.description) {
// Cleanup unused images
const regex = /"src":"https:\/\/[^"]+\/media\/image\/([^/]+)\.webp"/g;
const regex = /"src":"https:\/\/[^"]+\/media\/image\/([0-9a-f-]{36})\.webp"/g;
const imagesInUse = new Set<string>();
for (const field in updated.descriptionTranslation)
for (const match of (<string>updated.descriptionTranslation[field])?.matchAll(regex) ?? [])
imagesInUse.add(match[1]);
const currentImages = new Set(
(
await this.prisma.imageMedia.findMany({
where: { descriptionForAssos: { some: { id: assoId } } },
select: { id: true },
})
).map((m) => m.id),
);
const deletions = [...currentImages].filter((x) => !imagesInUse.has(x));
const additions = [...imagesInUse].filter((x) => !currentImages.has(x));
const existingAdditionIds = new Set(
(
await this.prisma.imageMedia.findMany({
where: { id: { in: additions } },
select: { id: true },
})
).map((m) => m.id),
);
const currentImages = (
await this.prisma.imageMedia.findMany({
where: { descriptionForAssos: { some: { id: assoId } } },
select: { id: true },
})
).map((m) => m.id);
const deletions = currentImages.filter((x) => !imagesInUse.has(x));
const additions = [...imagesInUse].filter((x) => !currentImages.includes(x));
const existingAdditionIds = (
await this.prisma.imageMedia.findMany({
where: { id: { in: additions } },
select: { id: true },
})
).map((m) => m.id);
if (deletions.length > 0 || additions.length > 0)
await this.prisma.$transaction([
...Array.from(deletions).map((id) =>
...deletions.map((id) =>
this.prisma.imageMedia.update({
where: { id },
data: { descriptionForAssos: { disconnect: { id: assoId } } },
}),
),
...Array.from(additions.filter((x) => existingAdditionIds.has(x))).map((id) =>
...Array.from(additions.filter((x) => existingAdditionIds.includes(x))).map((id) =>
this.prisma.imageMedia.update({
where: { id },
data: { descriptionForAssos: { connect: { id: assoId } } },
Expand Down
2 changes: 1 addition & 1 deletion src/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ export const ErrorData = Object.freeze({
httpCode: HttpStatus.SERVICE_UNAVAILABLE,
},
[ERROR_CODE.HIDDEN_DUCK]: {
message: 'Hey, you found the hidden duck ! Error : %',
message: 'Hey, you found the hidden duck ! Error: %',
httpCode: HttpStatus.I_AM_A_TEAPOT,
},
} as const) satisfies Readonly<{
Expand Down
14 changes: 14 additions & 0 deletions src/media/image/dto/req/imagemedia-upload-req.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ImageMediaPreset } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
Expand Down Expand Up @@ -31,13 +32,26 @@ export default class ImageMediaUploadReqDto {
@Type(() => Number)
effort?: number;

/** The number of 90° clockwise rotations to be performed. Defaults to 0 */
@ApiProperty({
default: 0,
enum: [0, 1, 2, 3],
required: false,
description: 'The number of 90° clockwise rotations to be performed.',
})
@IsOptional()
@IsInt()
@Min(0)
@Max(3)
@Type(() => Number)
rotation?: 0 | 1 | 2 | 3;

@ApiProperty({
default: ImageMediaPreset.CUSTOM,
enum: Object.values(ImageMediaPreset),
required: false,
description: 'The preset to use for image processing.',
})
@IsOptional()
@IsString()
@IsEnum(ImageMediaPreset)
Expand Down
6 changes: 6 additions & 0 deletions src/media/image/dto/res/imagemedia-upload-res.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { ApiResponseProperty } from '@nestjs/swagger';
import { ImageMediaPreset } from '@prisma/client';

export default class ImageMediaUploadResDto {
@ApiResponseProperty({ format: 'uuid' })
id: string;
@ApiResponseProperty({ example: 256 })
width: number;
@ApiResponseProperty({ example: 256 })
height: number;
@ApiResponseProperty({ example: 34567 })
size: number;
isPublic: boolean;
@ApiResponseProperty({ enum: Object.values(ImageMediaPreset) })
preset: ImageMediaPreset;
}
2 changes: 2 additions & 0 deletions src/media/image/imagemedia.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export class ImageMediaController {
stream.on('error', () => {
stream.close();
const exception = new AppException(ERROR_CODE.SERVER_DISK_ERROR);
response.setHeader('Content-Type', 'application/json');
response.setHeader('Cache-Control', 'no-cache, no-store');
response.status(exception.getStatus()).json(exception.getResponse());
});
}
Expand Down
46 changes: 23 additions & 23 deletions src/media/image/imagemedia.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export class ImageMediaService {
readonly config: ConfigModule,
) {}

async convertMedia(file: MulterWithMime, options: ConversionOptions): Promise<ImageMetadata> {
async convertMedia(file: MulterWithMime, options: ImageMediaUploadReqDto): Promise<ImageMetadata> {
if (!(options.preset in presets)) options.preset = ImageMediaPreset.CUSTOM;
if (options.preset) Object.assign(options, presets[options.preset]);
Object.assign(options, presets[options.preset]);
let instructions = sharp(file.multer.buffer);
let metadata = await instructions.metadata();
const size = [metadata.width, metadata.height];
Expand All @@ -55,10 +55,10 @@ export class ImageMediaService {
return { width: metadata.width, height: metadata.height, size: file.multer.buffer.length, preset: options.preset };
}

async registerMedia(metaData: ImageMetadata, uploader: User, isPublic: boolean): Promise<ImageMedia> {
async registerMedia(metadata: ImageMetadata, uploader: User, isPublic: boolean): Promise<ImageMedia> {
const image = await this.prisma.imageMedia.create({
data: {
...metaData,
...metadata,
uploader: {
connect: { id: uploader.id },
},
Expand All @@ -76,22 +76,37 @@ export class ImageMediaService {
return this.prisma.imageMedia.delete({ where: { id: mediaId } });
}

async getMedia(mediaId: string) {
async getMedia(mediaId: string): Promise<ImageMedia> {
return this.prisma.imageMedia.findUnique({ where: { id: mediaId } });
}

async writeMediaToDisk(mediaId: string, buffer: Buffer): Promise<void> {
await writeFile(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`, buffer);
}

readMediaFromDisk(mediaId: string): ReadStream {
return createReadStream(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`);
}

async cleanup() {
const media = await this.clearUnusedMedia();
const deletions = media.map((m) => this.deleteMediaFromDisk(m.id).catch(() => m)); // return media on failure
const failedDeletions = (await Promise.all(deletions)).filter((r): r is ImageMedia => r !== undefined);
failedDeletions.map(this.rollbackMedia); // Restore failed media, no need to wait for completion
}

/**
* Clears unused from the Database. {@link ImageMediaService.deleteMediaFromDisk DeleteMediaFromDisk} must be called
* with the output of this method to clear data from disk.
*/
async clearUnusedMedia(): Promise<ImageMedia[]> {
private async clearUnusedMedia(): Promise<ImageMedia[]> {
const targetMedias = await this.prisma.imageMedia.findMany({
where: {
// Filter explanation https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries#filter-on-absence-of--to-many-records
avatarForUsers: { none: {} },
logoForAssos: { none: {} },
descriptionForAssos: { none: {} },
uploadedAt: { lt: new Date(Date.now() - this.config.MEDIA_DETACHED_LIFESPAN * 3_600_000) },
uploadedAt: { lt: new Date(Date.now() - this.config.MEDIA_DETACHED_LIFESPAN * 86_400_000) },
},
});
await this.prisma.imageMedia.deleteMany({
Expand All @@ -100,22 +115,7 @@ export class ImageMediaService {
return targetMedias;
}

async writeMediaToDisk(mediaId: string, buffer: Buffer): Promise<void> {
await writeFile(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`, buffer);
}

readMediaFromDisk(mediaId: string): ReadStream {
return createReadStream(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`);
}

async deleteMediaFromDisk(mediaId: string): Promise<void> {
private async deleteMediaFromDisk(mediaId: string): Promise<void> {
await rm(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`, { force: true });
}

async cleanup() {
const media = await this.clearUnusedMedia();
(await Promise.all(media.map((m) => this.deleteMediaFromDisk(m.id).catch(() => m)))).map(
(r) => r && this.rollbackMedia(r),
);
}
}
3 changes: 2 additions & 1 deletion src/users/dto/req/users-update-req.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { AddressPrivacy, Language } from '@prisma/client';
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';

export class UserUpdateReqDto {
@IsString()
@IsOptional()
nickname?: string;

@IsString()
@IsUUID()
@IsOptional()
avatar?: string;

Expand Down
13 changes: 12 additions & 1 deletion src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto';
import UserDetailResDto from './dto/res/user-detail-res.dto';
import UserBirthdayResDto from './dto/res/user-birthday-res.dto';
import UserAssoMembershipResDto from './dto/res/user-asso-membership-res.dto';
import { ImageMediaService } from '../media/image/imagemedia.service';
import { ImageMediaPreset } from '@prisma/client';

@Controller('users')
@ApiTags('User')
export default class UsersController {
constructor(private usersService: UsersService) {}
constructor(
private usersService: UsersService,
private mediaService: ImageMediaService,
) {}

@Get()
@ApiOperation({
Expand Down Expand Up @@ -83,6 +88,12 @@ export default class UsersController {
async updateInfos(@GetUser() user: User, @Body() dto: UserUpdateReqDto): Promise<UserDetailResDto> {
if (Object.values(dto).every((element) => element === undefined))
throw new AppException(ERROR_CODE.NO_FIELD_PROVIDED);
if (dto.avatar) {
const media = await this.mediaService.getMedia(dto.avatar);
if (!media) throw new AppException(ERROR_CODE.NO_SUCH_MEDIA, dto.avatar);
if (media.preset !== ImageMediaPreset.AVATAR)
throw new AppException(ERROR_CODE.MEDIA_PRESET_REQUIRED, ImageMediaPreset.AVATAR);
}
await this.usersService.updateUserProfil(user.id, dto);
return this.formatUserDetails(await this.usersService.fetchUser(user.id), true);
}
Expand Down
8 changes: 7 additions & 1 deletion src/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { Global, Module } from '@nestjs/common';
import UsersController from './users.controller';
import UsersService from './users.service';
import { ImageMediaModule } from '../media/image/imagemedia.module';

@Global()
@Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService] })
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
imports: [ImageMediaModule],
})
export class UsersModule {}
Loading