diff --git a/apps/catalog/src/catalog.controller.spec.ts b/apps/catalog/src/catalog.controller.spec.ts index cc72ca0..5d5e1b1 100644 --- a/apps/catalog/src/catalog.controller.spec.ts +++ b/apps/catalog/src/catalog.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CatalogController } from './catalog.controller'; -import { CatalogService } from './catalog.service'; +import { CatalogService } from './services/catalog.service'; describe('CatalogController', () => { let catalogController: CatalogController; diff --git a/apps/catalog/src/catalog.controller.ts b/apps/catalog/src/catalog.controller.ts index 5ead3b9..3adbc60 100644 --- a/apps/catalog/src/catalog.controller.ts +++ b/apps/catalog/src/catalog.controller.ts @@ -1,12 +1,116 @@ -import { Controller, Get } from '@nestjs/common'; -import { CatalogService } from './catalog.service'; +import { Controller } from '@nestjs/common'; +import { CatalogService } from './services/catalog.service'; +import { MessagePattern } from '@nestjs/microservices'; +import { PageQuery, UserInfo } from '@freeblox/shared'; +import { ContentAssetType } from './enums/content-asset-type.enum'; +import { + CreateContentRequest, + CreateContentRevisionRequest, +} from './interfaces/create-content-request.interface'; +import { CreateContentService } from './services/create-content.service'; @Controller() export class CatalogController { - constructor(private readonly catalogService: CatalogService) {} + constructor( + private readonly catalogService: CatalogService, + private readonly createContentService: CreateContentService, + ) {} - @Get() - getHello(): string { - return this.catalogService.getHello(); + // Getters + + @MessagePattern('catalog.items.categories') + async getCatalogCategories() { + return this.catalogService.getCategoryTree(); + } + + @MessagePattern('catalog.items.byId') + async getItemById({ id, user }: { id: number; user?: UserInfo }) { + return this.catalogService.getCatalogItemById(id, user); + } + + @MessagePattern('catalog.assets.byId') + async getItemAssets({ + id, + type, + typeName, + }: { + id: number; + type?: ContentAssetType[]; + typeName?: string[]; + }) { + return this.catalogService.getCatalogItemAssets(id, type, typeName); + } + + @MessagePattern('catalog.comments.byId') + async getItemComments({ + id, + paging, + user, + }: { + id: number; + paging?: PageQuery; + user?: UserInfo; + }) { + return this.catalogService.getCatalogItemComments(id, paging, user); + } + + @MessagePattern('catalog.votes.byId') + async getItemVotes({ id, user }: { id: number; user?: UserInfo }) { + return this.catalogService.getContentVotes(id, user); + } + + // Actions + + @MessagePattern('catalog.items.create') + async createItem({ + body, + user, + files, + gameId, + }: { + body: CreateContentRequest; + user: UserInfo; + files?: Express.Multer.File[]; + gameId?: number; + }) { + return this.createContentService.createContent(body, user, files, gameId); + } + + @MessagePattern('catalog.items.createRevision') + async createItemRevision({ + id, + body, + user, + files, + }: { + id: number; + body: CreateContentRevisionRequest; + user: UserInfo; + files?: Express.Multer.File[]; + }) { + return this.createContentService.createContentRevision( + id, + body, + user, + files, + ); + } + + @MessagePattern('catalog.items.update') + async updateItem({ + id, + body, + user, + }: { + id: number; + body: Partial; + user: UserInfo; + }) { + return this.createContentService.updateContentDetails(id, body, user); + } + + @MessagePattern('catalog.items.publish') + async publishItem({ id, user }: { id: number; user: UserInfo }) { + return this.createContentService.publishContentItem(id, user); } } diff --git a/apps/catalog/src/catalog.module.ts b/apps/catalog/src/catalog.module.ts index d53756f..ac7a928 100644 --- a/apps/catalog/src/catalog.module.ts +++ b/apps/catalog/src/catalog.module.ts @@ -1,6 +1,6 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { CatalogController } from './catalog.controller'; -import { CatalogService } from './catalog.service'; +import { CatalogService } from './services/catalog.service'; import { ClientsModule } from '@nestjs/microservices'; import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -19,6 +19,27 @@ import { ContentTradeEntity } from './database/entities/content-trade.entity'; import { ContentFavoriteEntity } from './database/entities/content-favorite.entity'; import { ContentVoteEntity } from './database/entities/content-vote.entity'; import { ContentReportEntity } from './database/entities/content-report.entity'; +import { + ContentCategoryContentTypeEntity, + ContentCategoryEntity, +} from './database/entities/content-category.entity'; +import { CreateContentService } from './services/create-content.service'; + +const entities = [ + ContentEntity, + ContentRevisionEntity, + ContentModerationEntity, + ContentModerationBanEntity, + ContentAssetEntity, + ContentPriceEntity, + ContentOwnershipEntity, + ContentTradeEntity, + ContentFavoriteEntity, + ContentVoteEntity, + ContentReportEntity, + ContentCategoryEntity, + ContentCategoryContentTypeEntity, +]; @Module({ imports: [ @@ -29,21 +50,12 @@ import { ContentReportEntity } from './database/entities/content-report.entity'; TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: (config: ConfigService) => config.get('typeorm'), + useFactory: (config: ConfigService) => ({ + ...config.get('typeorm'), + entities, + }), }), - TypeOrmModule.forFeature([ - ContentEntity, - ContentRevisionEntity, - ContentModerationEntity, - ContentModerationBanEntity, - ContentAssetEntity, - ContentPriceEntity, - ContentOwnershipEntity, - ContentTradeEntity, - ContentFavoriteEntity, - ContentVoteEntity, - ContentReportEntity, - ]), + TypeOrmModule.forFeature(entities), ClientsModule.register([ natsClient('catalog'), natsClient('auth'), @@ -51,7 +63,7 @@ import { ContentReportEntity } from './database/entities/content-report.entity'; ]), ], controllers: [CatalogController], - providers: [CatalogService], + providers: [CatalogService, CreateContentService], }) export class CatalogModule implements OnModuleInit { constructor(private readonly config: ConfigService) {} @@ -59,6 +71,6 @@ export class CatalogModule implements OnModuleInit { async onModuleInit() { const knexInstance = knex(this.config.get('knex')); await knexInstance.migrate.latest(); - // await knexInstance.seed.run(); + await knexInstance.seed.run(); } } diff --git a/apps/catalog/src/catalog.service.ts b/apps/catalog/src/catalog.service.ts deleted file mode 100644 index 03331c9..0000000 --- a/apps/catalog/src/catalog.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class CatalogService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/apps/catalog/src/constants/constants.ts b/apps/catalog/src/constants/constants.ts new file mode 100644 index 0000000..67a9ecb --- /dev/null +++ b/apps/catalog/src/constants/constants.ts @@ -0,0 +1,53 @@ +import { ContentType } from '../enums/content-type.enum'; + +export const CHARACTER_CLOTHING = [ + ContentType.TSHIRT, + ContentType.SHIRT, + ContentType.PANTS, +]; + +export const CHARACTER_ACCESSORIES = [ + ContentType.ACCESSORY, + ContentType.HAT, + ContentType.FACE, + ContentType.FRONT, + ContentType.BODY, + ContentType.BACK, + ContentType.TOOL, +]; + +export const CREATOR_ITEMS = [ + ContentType.MESH, + ContentType.TEXTURE, + ContentType.GAMEOBJECT, + ContentType.SOUND, + ContentType.ANIMATION, +]; + +export const CATALOG_CATEGORY_ITEMS = [ + ContentType.CHARACTER, + ...CHARACTER_CLOTHING, + ...CHARACTER_ACCESSORIES, + ...CREATOR_ITEMS, +]; + +export const ALLOWED_IMAGE_MIME = [ + 'image/bmp', + 'image/jpeg', + 'image/jpg', + 'image/png', +]; + +export const ALLOWED_MESH_MIME = [ + 'model/obj', + 'model/gltf-binary', + 'model/gltf+json', +]; + +export const ALLOWED_SOUND_MIME = [ + 'audio/x-wav', + 'audio/mpeg', + 'audio/ogg', + 'audio/vorbis', + 'application/ogg', +]; diff --git a/apps/catalog/src/database/entities/content-asset.entity.ts b/apps/catalog/src/database/entities/content-asset.entity.ts index 4f58009..efcd197 100644 --- a/apps/catalog/src/database/entities/content-asset.entity.ts +++ b/apps/catalog/src/database/entities/content-asset.entity.ts @@ -25,13 +25,13 @@ export class ContentAssetEntity { @Column({ name: 'asset_id', type: 'uuid' }) assetId: string; - @Column({ type: 'string', enum: ContentAssetType }) + @Column({ type: String, enum: ContentAssetType }) type: ContentAssetType; @Column({ name: 'type_name', nullable: true }) typeName?: string; - @Column() + @Column({ default: 0 }) index: number; @ManyToOne(() => ContentEntity, { onDelete: 'CASCADE' }) diff --git a/apps/catalog/src/database/entities/content-category.entity.ts b/apps/catalog/src/database/entities/content-category.entity.ts new file mode 100644 index 0000000..4d7c5b4 --- /dev/null +++ b/apps/catalog/src/database/entities/content-category.entity.ts @@ -0,0 +1,63 @@ +import { Exclude, Expose } from 'class-transformer'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + Tree, + TreeChildren, + TreeParent, +} from 'typeorm'; +import { ContentType } from '../../enums/content-type.enum'; + +@Entity('content_category') +@Tree('materialized-path') +@Exclude() +export class ContentCategoryEntity { + @PrimaryGeneratedColumn() + @Expose() + id: number; + + @Column() + @Expose() + category: string; + + @Column({ name: 'parent_id' }) + parentId: number; + + @TreeParent() + @JoinColumn({ name: 'parent_id' }) + parent: ContentCategoryEntity; + + @TreeChildren() + @Expose() + children: ContentCategoryEntity[]; + + @OneToMany( + () => ContentCategoryContentTypeEntity, + (entity) => entity.category, + { eager: true }, + ) + @Expose() + contentTypes: ContentCategoryContentTypeEntity[]; +} + +@Entity('content_category_content_type') +@Exclude() +export class ContentCategoryContentTypeEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'category_id' }) + categoryId: number; + + @Column({ name: 'content_type', type: String, enum: ContentType }) + @Expose() + contentType: ContentType; + + @ManyToOne(() => ContentCategoryEntity) + @JoinColumn({ name: 'category_id' }) + category: ContentCategoryEntity; +} diff --git a/apps/catalog/src/database/entities/content-moderation.entity.ts b/apps/catalog/src/database/entities/content-moderation.entity.ts index 16404bc..debdb16 100644 --- a/apps/catalog/src/database/entities/content-moderation.entity.ts +++ b/apps/catalog/src/database/entities/content-moderation.entity.ts @@ -94,6 +94,10 @@ export class ContentModerationEntity extends MetaEntity { @Entity('content_moderation_ban') export class ContentModerationBanEntity { + @PrimaryGeneratedColumn() + @Expose() + id: number; + @Column({ nullable: true, name: 'ban_id' }) banId: number; diff --git a/apps/catalog/src/database/entities/content-revision.entity.ts b/apps/catalog/src/database/entities/content-revision.entity.ts index edc0176..153b802 100644 --- a/apps/catalog/src/database/entities/content-revision.entity.ts +++ b/apps/catalog/src/database/entities/content-revision.entity.ts @@ -7,9 +7,11 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { ContentEntity } from './content.entity'; +import { ContentAssetEntity } from './content-asset.entity'; @Entity('content_revision') @Exclude() @@ -32,4 +34,7 @@ export class ContentRevisionEntity extends UserMetaEntity { @ManyToOne(() => ContentEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'content_id' }) content: ContentEntity; + + @OneToMany(() => ContentAssetEntity, (asset) => asset.revision) + assets: ContentAssetEntity[]; } diff --git a/apps/catalog/src/database/entities/content.entity.ts b/apps/catalog/src/database/entities/content.entity.ts index df94dcd..506a0a6 100644 --- a/apps/catalog/src/database/entities/content.entity.ts +++ b/apps/catalog/src/database/entities/content.entity.ts @@ -7,6 +7,7 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { Privacy } from '../../enums/privacy.enum'; @@ -22,6 +23,7 @@ import { IsString, MaxLength, } from 'class-validator'; +import { ContentPriceEntity } from './content-price.entity'; @Entity('content') @Expose() @@ -77,7 +79,7 @@ export class ContentEntity extends UserMetaEntity { @IsEnum(Privacy) privacy: Privacy; - @Column({ type: 'string', enum: ContentType }) + @Column({ type: String, enum: ContentType }) @IsEnum(ContentType) @Index() type: ContentType; @@ -105,5 +107,9 @@ export class ContentEntity extends UserMetaEntity { @Exclude() deletedAt?: Date; - user?: UserEntity; + @OneToMany(() => ContentPriceEntity, (price) => price.content) + @Expose() + prices?: ContentPriceEntity[]; + + user?: Partial; } diff --git a/apps/catalog/src/database/migrations/20230722091246_content-moderator-action.ts b/apps/catalog/src/database/migrations/20230722091246_content-moderator-action.ts index eeba263..61967cd 100644 --- a/apps/catalog/src/database/migrations/20230722091246_content-moderator-action.ts +++ b/apps/catalog/src/database/migrations/20230722091246_content-moderator-action.ts @@ -36,6 +36,7 @@ export async function up(knex: Knex): Promise { .onDelete('CASCADE'); }), knex.schema.createTable('content_moderation_ban', (table) => { + table.increments('id').primary(); table.integer('content_moderation_id').unsigned().notNullable(); table.integer('ban_id').unsigned().nullable(); table diff --git a/apps/catalog/src/database/migrations/20230722114124_content-category.ts b/apps/catalog/src/database/migrations/20230722114124_content-category.ts new file mode 100644 index 0000000..e89b3cf --- /dev/null +++ b/apps/catalog/src/database/migrations/20230722114124_content-category.ts @@ -0,0 +1,32 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await Promise.all([ + knex.schema.createTable('content_category', (table) => { + table.increments('id').primary(); + table.text('category').notNullable(); + table.string('mpath').nullable().defaultTo(''); + table.integer('parent_id').nullable().unsigned(); + table + .foreign('parent_id') + .references('content_category.id') + .onDelete('SET NULL'); + }), + knex.schema.createTable('content_category_content_type', (table) => { + table.increments('id').primary(); + table.integer('category_id').nullable().unsigned(); + table.string('content_type').notNullable(); + table + .foreign('category_id') + .references('content_category.id') + .onDelete('SET NULL'); + }), + ]); +} + +export async function down(knex: Knex): Promise { + Promise.all([ + knex.schema.dropTable('content_category'), + knex.schema.dropTable('content_category_content_type'), + ]); +} diff --git a/apps/catalog/src/database/seeds/0001-initial-categories.ts b/apps/catalog/src/database/seeds/0001-initial-categories.ts new file mode 100644 index 0000000..e5beabc --- /dev/null +++ b/apps/catalog/src/database/seeds/0001-initial-categories.ts @@ -0,0 +1,200 @@ +import { Knex } from 'knex'; +import { ContentType } from '../../enums/content-type.enum'; + +const categories = [ + { + name: 'characters', + parent: null, + contentTypes: [ContentType.CHARACTER], + id: 0, + path: '', + }, + { + name: 'clothing', + parent: null, + contentTypes: [ContentType.TSHIRT, ContentType.SHIRT, ContentType.PANTS], + id: 0, + path: '', + }, + { + name: 'tshirts', + parent: 'clothing', + contentTypes: [ContentType.TSHIRT], + id: 0, + path: '', + }, + { + name: 'shirts', + parent: 'clothing', + contentTypes: [ContentType.SHIRT], + id: 0, + path: '', + }, + { + name: 'pants', + parent: 'clothing', + contentTypes: [ContentType.PANTS], + id: 0, + path: '', + }, + { + name: 'accessories', + parent: null, + contentTypes: [ + ContentType.ACCESSORY, + ContentType.HAT, + ContentType.FACE, + ContentType.FRONT, + ContentType.BODY, + ContentType.BACK, + ContentType.TOOL, + ], + id: 0, + path: '', + }, + { + name: 'hats', + parent: 'accessories', + contentTypes: [ContentType.HAT], + id: 0, + path: '', + }, + { + name: 'faces', + parent: 'accessories', + contentTypes: [ContentType.FACE], + id: 0, + path: '', + }, + { + name: 'front', + parent: 'accessories', + contentTypes: [ContentType.FRONT], + id: 0, + path: '', + }, + { + name: 'body', + parent: 'accessories', + contentTypes: [ContentType.BODY], + id: 0, + path: '', + }, + { + name: 'back', + parent: 'accessories', + contentTypes: [ContentType.BACK], + id: 0, + path: '', + }, + { + name: 'tools', + parent: 'accessories', + contentTypes: [ContentType.TOOL], + id: 0, + path: '', + }, + { + name: 'creator', + parent: null, + contentTypes: [ + ContentType.MESH, + ContentType.TEXTURE, + ContentType.GAMEOBJECT, + ContentType.SOUND, + ContentType.ANIMATION, + ], + id: 0, + path: '', + }, + { + name: 'meshes', + parent: 'creator', + contentTypes: [ContentType.MESH], + id: 0, + path: '', + }, + { + name: 'textures', + parent: 'creator', + contentTypes: [ContentType.TEXTURE], + id: 0, + path: '', + }, + { + name: 'gameobjects', + parent: 'creator', + contentTypes: [ContentType.GAMEOBJECT], + id: 0, + path: '', + }, + { + name: 'sounds', + parent: 'creator', + contentTypes: [ContentType.SOUND], + id: 0, + path: '', + }, + { + name: 'animation', + parent: 'creator', + contentTypes: [ContentType.ANIMATION], + id: 0, + path: '', + }, +]; + +export async function seed(knex: Knex): Promise { + for (const type of categories) { + const existing = await knex('content_category').where({ + category: type.name, + }); + + let parentId: number; + let parentPath = ''; + + if (type.parent) { + const parentItem = categories.find((entry) => entry.name === type.parent); + if (parentItem?.id) { + parentId = parentItem.id; + parentPath = parentItem.path; + } + } + + if (existing?.length) { + type.id = existing[0].id; + type.path = parentPath + ? parentPath + '.' + existing[0].id + : existing[0].id.toString(); + } else { + const [newItem] = await knex('content_category') + .insert({ + category: type.name, + parent_id: parentId, + }) + .returning(['id']); + + type.id = newItem.id; + type.path = parentPath + ? parentPath + '.' + newItem.id + : newItem.id.toString(); + + await knex('content_category') + .where({ id: newItem.id }) + .update({ mpath: type.path }); + } + + for (const contentType of type.contentTypes) { + const existing = await knex('content_category_content_type').where({ + content_type: contentType, + category_id: type.id, + }); + if (existing?.length) continue; + + await knex('content_category_content_type').insert({ + content_type: contentType, + category_id: type.id, + }); + } + } +} diff --git a/apps/catalog/src/dtos/content-user.dto.ts b/apps/catalog/src/dtos/content-user.dto.ts new file mode 100644 index 0000000..06991b7 --- /dev/null +++ b/apps/catalog/src/dtos/content-user.dto.ts @@ -0,0 +1,13 @@ +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class ContentUserDto { + @Expose() + id: string; + + @Expose() + username: string; + + @Expose() + displayName: string; +} diff --git a/apps/catalog/src/enums/content-type.enum.ts b/apps/catalog/src/enums/content-type.enum.ts index 9160ce9..b72893f 100644 --- a/apps/catalog/src/enums/content-type.enum.ts +++ b/apps/catalog/src/enums/content-type.enum.ts @@ -3,9 +3,14 @@ export enum ContentType { CHARACTER = 'character', HAT = 'hat', ACCESSORY = 'accessory', + FACE = 'face', FRONT = 'front', BACK = 'back', + BODY = 'body', TOOL = 'tool', + TSHIRT = 'tshirt', + SHIRT = 'shirt', + PANTS = 'pants', MESH = 'mesh', TEXTURE = 'texture', GAMEOBJECT = 'gameobject', diff --git a/apps/catalog/src/enums/order-by.enum.ts b/apps/catalog/src/enums/order-by.enum.ts new file mode 100644 index 0000000..38c8a94 --- /dev/null +++ b/apps/catalog/src/enums/order-by.enum.ts @@ -0,0 +1,8 @@ +export enum OrderBy { + DEFAULT = 'default', + POPULAR = 'popular', + NEWEST = 'newest', + OLDEST = 'oldest', + PRICE_LOW = 'price-low', + PRICE_HIGH = 'price-high', +} diff --git a/apps/catalog/src/interfaces/content-filter-options.interface.ts b/apps/catalog/src/interfaces/content-filter-options.interface.ts new file mode 100644 index 0000000..2202f66 --- /dev/null +++ b/apps/catalog/src/interfaces/content-filter-options.interface.ts @@ -0,0 +1,15 @@ +import { ContentType } from '../enums/content-type.enum'; +import { Currency } from '../enums/currency.enum'; + +export interface ContentFilterPriceOptions { + currency?: Currency; + min?: number; + max?: number; +} + +export interface ContentFilterOptions { + search?: string; + contentTypes?: ContentType[]; + pricing?: ContentFilterPriceOptions[]; + onSale?: boolean; +} diff --git a/apps/catalog/src/interfaces/create-content-request.interface.ts b/apps/catalog/src/interfaces/create-content-request.interface.ts new file mode 100644 index 0000000..94907a8 --- /dev/null +++ b/apps/catalog/src/interfaces/create-content-request.interface.ts @@ -0,0 +1,38 @@ +import { ContentAssetType } from '../enums/content-asset-type.enum'; +import { ContentType } from '../enums/content-type.enum'; +import { Currency } from '../enums/currency.enum'; +import { Privacy } from '../enums/privacy.enum'; + +export interface ContentAssetTypeProperties { + type: ContentAssetType; + typeName?: string; +} + +export interface CreateContentFiles { + [x: string]: ContentAssetTypeProperties; +} + +export interface CreateContentPricing { + currency: Currency; + price: number; +} + +export interface CreateContentRequest { + name: string; + description: string; + parentId?: number; + onsale?: boolean; + commentsEnabled?: boolean; + openSource?: boolean; + privacy: Privacy; + type: ContentType; + stock?: number; + license?: string; + + files?: CreateContentFiles; + prices?: CreateContentPricing[]; +} + +export interface CreateContentRevisionRequest { + files?: CreateContentFiles; +} diff --git a/apps/catalog/src/main.ts b/apps/catalog/src/main.ts index 2c77549..e5f651c 100644 --- a/apps/catalog/src/main.ts +++ b/apps/catalog/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { CatalogModule } from './catalog.module'; import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { HttpRpcExceptionFilter } from '@freeblox/shared'; async function bootstrap() { const app = await NestFactory.createMicroservice( @@ -13,6 +14,7 @@ async function bootstrap() { }, ); + app.useGlobalFilters(new HttpRpcExceptionFilter()); await app.listen(); } bootstrap(); diff --git a/apps/catalog/src/services/catalog.service.ts b/apps/catalog/src/services/catalog.service.ts new file mode 100644 index 0000000..cca60cb --- /dev/null +++ b/apps/catalog/src/services/catalog.service.ts @@ -0,0 +1,216 @@ +import { + NotFoundRpcException, + PageQuery, + PreconditionFailedRpcException, + UserInfo, +} from '@freeblox/shared'; +import { Inject, Injectable } from '@nestjs/common'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { EntityManager, In } from 'typeorm'; +import { ContentEntity } from '../database/entities/content.entity'; +import { Privacy } from '../enums/privacy.enum'; +import { instanceToPlain, plainToClass } from 'class-transformer'; +import { ContentRevisionEntity } from '../database/entities/content-revision.entity'; +import { ContentAssetType } from '../enums/content-asset-type.enum'; +import { ContentType } from '../enums/content-type.enum'; +import { ClientProxy } from '@nestjs/microservices'; +import { lastValueFrom } from 'rxjs'; +import { ContentUserDto } from '../dtos/content-user.dto'; +import { ContentVoteEntity } from '../database/entities/content-vote.entity'; +import { Vote } from '../enums/vote.enum'; +import { ContentCategoryEntity } from '../database/entities/content-category.entity'; + +@Injectable() +export class CatalogService { + constructor( + @InjectEntityManager() private manager: EntityManager, + @Inject('auth') private authClient: ClientProxy, + ) {} + + /** + * Get the assets for the catalog item. + * @param itemId Item ID + * @returns Assets for the latest revision + */ + async getCatalogItemAssets( + itemId: number, + typeFilter?: ContentAssetType[], + typeNameFilter?: string[], + ) { + // Load the latest revision to get the assets + const latestRevision = await this.manager.findOne(ContentRevisionEntity, { + where: { + content: { id: itemId }, + assets: + typeFilter?.length || typeNameFilter?.length + ? { + type: typeFilter?.length ? In(typeFilter) : undefined, + typeName: typeNameFilter?.length + ? In(typeNameFilter) + : undefined, + } + : undefined, + }, + order: { createdAt: 'DESC' }, + relations: ['assets'], + }); + + if (!latestRevision) return []; + return latestRevision.assets; + } + + /** + * Get catalog item by ID. + * @param itemId Item ID + * @param user User + * @returns Catalog item + */ + async getCatalogItemById(itemId: number, user?: UserInfo) { + const findItem = await this.manager.findOne(ContentEntity, { + where: { id: itemId, deletedAt: null }, + relations: ['prices'], + }); + + if (!findItem) { + throw new NotFoundRpcException( + `Catalog item by ID ${itemId} was not found`, + ); + } + + // Permission checks + if ( + user && + !user.privileges.includes('root') && + !user.privileges.includes('contentedit') + ) { + // When private, only creator can access + // TODO: friends + if ( + findItem.privacy === Privacy.PRIVATE || + findItem.privacy === Privacy.FRIENDS + ) { + if (findItem.userId !== user.sub) { + throw new NotFoundRpcException( + `Catalog item by ID ${itemId} was not found`, + ); + } + } + } + + // Get the owner user + findItem.user = await this.getContentUser(findItem.userId); + + return instanceToPlain(findItem); + } + + /** + * Get paginated comments for catalog item by ID. + * @param itemId Catalog item ID + * @param paging Pagination + * @param user Requesting user + * @returns Comments + */ + async getCatalogItemComments( + itemId: number, + paging?: PageQuery, + user?: UserInfo, + ) { + const page = paging.page ?? 0; + const pageSize = paging.pageSize || 10; + + const catalogItem = await this.getCatalogItemById(itemId, user); + if (!catalogItem.commentsEnabled) { + throw new PreconditionFailedRpcException('Comments are disabled'); + } + + const [comments, total] = await this.manager.findAndCount(ContentEntity, { + where: { + type: ContentType.COMMENT, + privacy: Privacy.PUBLIC, + published: true, + deletedAt: null, + parent: { id: itemId }, + }, + order: { + createdAt: 'DESC', + }, + take: pageSize, + skip: page * pageSize, + }); + + const mapped = await this.mapComments(comments, user); + return { + pagination: { + total, + count: mapped.length, + page, + pageSize, + }, + list: instanceToPlain(mapped), + }; + } + + /** + * Get votes for a catalog item. + * @param itemId Content ID + * @param user User + * @returns Votes + */ + async getContentVotes(itemId: number, user?: UserInfo) { + await this.getCatalogItemById(itemId, user); + + const voteList = await this.manager.find(ContentVoteEntity, { + where: { contentId: itemId }, + }); + + return { + up: voteList.filter(({ vote }) => vote === Vote.UPVOTE).length, + down: voteList.filter(({ vote }) => vote === Vote.DOWNVOTE).length, + }; + } + + /** + * Get category tree for catalog + * @returns Category tree + */ + async getCategoryTree() { + const categoryTree = await this.manager + .getTreeRepository(ContentCategoryEntity) + .findTrees({ relations: ['contentTypes'] }); + return instanceToPlain(categoryTree); + } + + /** + * Get user information for content. + * @private + * @param userId User ID + * @returns User information DTO + */ + async getContentUser(userId: string) { + const user = await lastValueFrom( + this.authClient.send('auth.getUserById', { id: userId }), + ); + return plainToClass(ContentUserDto, user); + } + + /** + * Map comment objects. + * @private + * @param comments Comments list + * @param user User + * @returns Mapped comments + */ + async mapComments(comments: ContentEntity[], user?: UserInfo) { + for (const comment of comments) { + comment.user = await this.getContentUser(comment.userId); + + if (comment.restricted) { + if (!user.privileges.includes('contentmod')) { + comment.description = null; + comment.name = null; + } + } + } + return comments; + } +} diff --git a/apps/catalog/src/services/create-content.service.ts b/apps/catalog/src/services/create-content.service.ts new file mode 100644 index 0000000..c0b5483 --- /dev/null +++ b/apps/catalog/src/services/create-content.service.ts @@ -0,0 +1,555 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { EntityManager } from 'typeorm'; +import { + ContentAssetTypeProperties, + CreateContentFiles, + CreateContentPricing, + CreateContentRequest, + CreateContentRevisionRequest, +} from '../interfaces/create-content-request.interface'; +import { + BadRequestRpcException, + ForbiddenRpcException, + NotFoundRpcException, + PreconditionFailedRpcException, + UserInfo, + ValidationRpcException, + parseBoolean, +} from '@freeblox/shared'; +import { + ALLOWED_IMAGE_MIME, + ALLOWED_MESH_MIME, + ALLOWED_SOUND_MIME, + CHARACTER_ACCESSORIES, + CHARACTER_CLOTHING, + CREATOR_ITEMS, +} from '../constants/constants'; +import { ContentType } from '../enums/content-type.enum'; +import { ContentEntity } from '../database/entities/content.entity'; +import { validate } from 'class-validator'; +import { ContentPriceEntity } from '../database/entities/content-price.entity'; +import { ContentModerationEntity } from '../database/entities/content-moderation.entity'; +import { ContentRevisionEntity } from '../database/entities/content-revision.entity'; +import { ModeratorAction } from '../enums/moderation-action.enum'; +import { instanceToPlain } from 'class-transformer'; +import { ContentAssetEntity } from '../database/entities/content-asset.entity'; +import { ContentAssetType } from '../enums/content-asset-type.enum'; +import { randomUUID } from 'crypto'; +import { lastValueFrom } from 'rxjs'; + +@Injectable() +export class CreateContentService { + constructor( + @InjectEntityManager() private manager: EntityManager, + @Inject('auth') private authClient: ClientProxy, + @Inject('catalog') private client: ClientProxy, + ) {} + + async createContent( + body: CreateContentRequest, + user: UserInfo, + files?: Express.Multer.File[], + gameId?: number, + ) { + if (!body.type) { + throw new BadRequestRpcException('Content type is mandatory.'); + } + + // Check permission for create + this.checkCreatePermission(body.type, user); + + const newContent = await this.manager.transaction(async (manager) => { + // Main object + // TODO: automatic moderation for name and description + const content = manager.create(ContentEntity, { + name: body.name, + description: body.description, + userId: user.sub, + parentId: body.parentId, + onsale: parseBoolean(body.onsale) || false, + published: false, + commentsEnabled: parseBoolean(body.commentsEnabled) || false, + openSource: parseBoolean(body.openSource) || false, + privacy: body.privacy, + createdBy: user.sub, + updatedBy: user.sub, + type: body.type, + stock: body.stock, + license: body.license, + }); + + const errors = await validate(content); + if (errors?.length) new ValidationRpcException(); + + // Set the object ID to the same as the game ID. + // Game object is created first. + if (gameId) { + content.id = gameId; + } + + await manager.save(ContentEntity, content); + + // Save pricing + await this.saveContentPricing(content, manager, body.prices); + + return content; + }); + + // Save content revision + await this.createContentRevision( + newContent.id, + { files: body.files }, + user, + files, + ); + + // TODO: log save + + return instanceToPlain(newContent); + } + + /** + * Update content details + * @param contentId Content ID + * @param body Update body + * @param user User token + * @returns Updated content + */ + async updateContentDetails( + contentId: number, + body: Partial, + user: UserInfo, + ) { + const content = await this.manager.findOne(ContentEntity, { + where: { + id: contentId, + userId: user.sub, // TODO: delegate update permissions + restricted: false, + deletedAt: null, + }, + }); + + if (!content) { + throw new NotFoundRpcException(`Content ${contentId} not found`); + } + + const updatedContent = await this.manager.transaction(async (manager) => { + // Main object + // TODO: automatic moderation for name and description + const data = manager.merge(ContentEntity, content, { + name: body.name, + description: body.description, + userId: user.sub, + parentId: body.parentId, + onsale: parseBoolean(body.onsale), + commentsEnabled: parseBoolean(body.commentsEnabled), + openSource: parseBoolean(body.openSource), + privacy: body.privacy, + updatedBy: user.sub, + type: body.type, + stock: Number(body.stock) || null, + license: body.license, + }); + + const errors = await validate(data); + if (errors?.length) new ValidationRpcException(); + + await manager.save(ContentEntity, data); + + // Save pricing + await this.saveContentPricing(data, manager, body.prices); + + return data; + }); + + // TODO: log save + + return instanceToPlain(updatedContent); + } + + /** + * Publish content, generate thumbnails + * @param contentId Content ID + * @param user User token + * @returns Updated content + */ + async publishContentItem(contentId: number, user: UserInfo) { + const content = await this.manager.findOne(ContentEntity, { + where: { + id: contentId, + userId: user.sub, // TODO: delegate update permissions + restricted: false, + deletedAt: null, + }, + }); + + if (!content) { + throw new NotFoundRpcException(`Content ${contentId} not found`); + } + + // TODO: generate thumbnails + + const getAssets = await lastValueFrom( + this.client.send('catalog.assets.byId', { id: contentId }), + ); + if (!getAssets.length) { + throw new PreconditionFailedRpcException( + 'You must publish some content to the platform first', + ); + } + + await this.manager.save( + ContentEntity, + this.manager.merge(ContentEntity, content, { + published: true, + }), + ); + + return instanceToPlain(content); + } + + /** + * Save content pricing. + * @param content Content + * @param manager Manager + * @param pricing Pricing + */ + async saveContentPricing( + content: ContentEntity, + manager: EntityManager, + pricing?: CreateContentPricing[], + ) { + if (!pricing) return; + + // Deduplicate currencies + // Can't have multiple prices for the same currency + const prices = (pricing || []).filter( + (item, index, array) => + array.findIndex((entry) => entry.currency === item.currency) === index, + ); + + // Delete existing currencies + const existingPricing = await manager.findBy(ContentPriceEntity, { + contentId: content.id, + }); + await Promise.all( + existingPricing + .filter( + (existing) => + !prices.some((entry) => entry.currency !== existing.currency), + ) + .map((entry) => manager.remove(ContentPriceEntity, entry)), + ); + + // Update or create pricing + for (const price of prices) { + const existing = existingPricing.find( + (entry) => entry.currency === price.currency, + ); + const priceObject = manager.create(ContentPriceEntity, { + currency: price.currency, + price: price.price, + contentId: content.id, + }); + + const priceErrors = await validate(priceObject); + if (priceErrors?.length) new ValidationRpcException(); + + // Update + if (existing) priceObject.id = existing.id; + + await manager.save(ContentPriceEntity, priceObject); + } + } + + /** + * Create a content revision and upload assets. + * @param contentId Content ID + * @param body Revision request + * @param files Attached files + */ + async createContentRevision( + contentId: number, + body: CreateContentRevisionRequest, + user: UserInfo, + files?: Express.Multer.File[], + ) { + // TODO: check paid service upload permission + + const content = await this.manager.findOne(ContentEntity, { + where: { + id: contentId, + userId: user.sub, // TODO: delegate revision permissions + deletedAt: null, + restricted: false, + }, + }); + + if (!content) { + throw new NotFoundRpcException(`Content ${contentId} not found`); + } + + const newRevision = this.manager.transaction(async (manager) => { + // Save new revision + const revision = await manager.save(ContentRevisionEntity, { + content, + createdBy: user.sub, + updatedBy: user.sub, + }); + + // Save assets + await this.uploadAssets(content, revision, body.files, files, manager); + + // Moderator review + await this.createPendingReview(content, revision, manager); + + return revision; + }); + + return instanceToPlain(newRevision); + } + + /** + * Create a pending moderator review for content revision. + * @private + * @param content Content + * @param revision Revision + * @param manager Entity manager + */ + async createPendingReview( + content: ContentEntity, + revision: ContentRevisionEntity, + manager = this.manager, + ) { + await manager.save(ContentModerationEntity, { + content, + revision, + action: ModeratorAction.PENDING, + }); + } + + /** + * Upload assets for content. + * @private + * @param types Asset types + * @param files Files to upload + * @returns Created assets + */ + async uploadAssets( + content: ContentEntity, + revision: ContentRevisionEntity, + types: CreateContentFiles = {}, + files: Express.Multer.File[] = [], + manager = this.manager, + ) { + const typeKeys = Object.keys(types); + if (!typeKeys.length) return []; + + const createdAssets: ContentAssetEntity[] = []; + + let fileIndex = 0; + for (const fileKey of typeKeys) { + const fileType = types[fileKey]; + const uploadedFile = files.find((file) => file.fieldname === fileKey); + if (!uploadedFile) { + throw new BadRequestRpcException( + `File was not included for key ${fileKey}`, + ); + } + + const allowed = await this.checkAssetType( + content.type, + fileType, + uploadedFile.mimetype, + ); + + if (!allowed) { + throw new BadRequestRpcException( + `File of type ${fileType.type} (${fileType.typeName || 'N/A'}) ${ + uploadedFile.mimetype + } is not allowed for content of type ${content.type}`, + ); + } + + const asset = manager.create(ContentAssetEntity, { + content, + revision, + assetId: randomUUID(), + type: fileType.type, + typeName: fileType.typeName, + index: fileIndex++, + }); + + const assetErrors = await validate(asset); + if (assetErrors?.length) new ValidationRpcException(); + + // TODO: actually upload the files somewhere + // TODO: convert file types into universal formats + + await manager.save(ContentAssetEntity, asset); + createdAssets.push(asset); + } + + return createdAssets; + } + + /** + * Check if provided asset type can be uploaded + * @private + * @param contentType Content type + * @param props Asset type properties + */ + async checkAssetType( + contentType: ContentType, + props: ContentAssetTypeProperties, + mime: string, + ) { + // TODO: check files for validity + // Textures for clothes and accessories + if ( + (CHARACTER_CLOTHING.includes(contentType) || + CHARACTER_ACCESSORIES.includes(contentType)) && + props.type === ContentAssetType.TEXTURE && + !props.typeName + ) { + if (!ALLOWED_IMAGE_MIME.includes(mime)) return false; + return true; + } + + // Meshes for accessories + if ( + CHARACTER_ACCESSORIES.includes(contentType) && + props.type === ContentAssetType.MESH && + !props.typeName + ) { + if (!ALLOWED_MESH_MIME.includes(mime)) return false; + return true; + } + + // Meshes + if ( + contentType === ContentType.MESH && + props.type === ContentAssetType.MESH && + !props.typeName + ) { + if (!ALLOWED_MESH_MIME.includes(mime)) return false; + return true; + } + + // Textures + if ( + contentType === ContentType.TEXTURE && + props.type === ContentAssetType.TEXTURE && + !props.typeName + ) { + if (!ALLOWED_IMAGE_MIME.includes(mime)) return false; + return true; + } + + // Game objects + if ( + contentType === ContentType.GAMEOBJECT && + props.type === ContentAssetType.GAMEOBJECT && + !props.typeName + ) { + if (mime !== 'application/json') return false; + return true; + } + + // Characters + if ( + contentType === ContentType.CHARACTER && + props.type === ContentAssetType.CHARACTER && + !props.typeName + ) { + if (mime !== 'application/json') return false; + return true; + } + + // World/Game + if ( + (contentType === ContentType.WORLD || contentType === ContentType.GAME) && + props.type === ContentAssetType.WORLD + ) { + if (mime !== 'application/json') return false; + return true; + } + + // Sound + if ( + contentType === ContentType.SOUND && + props.type === ContentAssetType.SOUND + ) { + if (!ALLOWED_SOUND_MIME.includes(mime)) return false; + return true; + } + + // World/Game thumbnail + if ( + (contentType === ContentType.WORLD || contentType === ContentType.GAME) && + props.type === ContentAssetType.IMAGE && + (props.typeName === 'thumbnail' || props.typeName === 'icon') + ) { + if (!ALLOWED_IMAGE_MIME.includes(mime)) return false; + return true; + } + + return false; + } + + /** + * Check if user is allowed to create this content. + * @private + * @param type Content type + * @param user User token + */ + checkCreatePermission(type: ContentType, user: UserInfo) { + // Check clothing permission + if ( + CHARACTER_CLOTHING.includes(type) && + !user.privileges.includes('create:clothing') + ) { + throw new ForbiddenRpcException( + `Create content of type ${type} is forbidden`, + ); + } + + // Check accessory permission + if ( + CHARACTER_ACCESSORIES.includes(type) && + !user.privileges.includes('create:accessory') + ) { + throw new ForbiddenRpcException( + `Create content of type ${type} is forbidden`, + ); + } + + // Check game permission + if ( + [...CREATOR_ITEMS, ContentType.GAME, ContentType.WORLD].includes(type) && + !user.privileges.includes('create:game') + ) { + throw new ForbiddenRpcException( + `Create content of type ${type} is forbidden`, + ); + } + + // Check character permission + if ( + type === ContentType.CHARACTER && + !user.privileges.includes('create:character') + ) { + throw new ForbiddenRpcException( + `Create content of type character is forbidden`, + ); + } + + if (type === ContentType.COMMENT || type === ContentType.STATUS) { + throw new BadRequestRpcException( + `Content of type ${type} cannot be created through this API`, + ); + } + } +} diff --git a/apps/freeblox-web-service/src/app.module.ts b/apps/freeblox-web-service/src/app.module.ts index 4694b51..2c470fb 100644 --- a/apps/freeblox-web-service/src/app.module.ts +++ b/apps/freeblox-web-service/src/app.module.ts @@ -4,6 +4,7 @@ import { AppService } from './app.service'; import { natsClient } from '@freeblox/shared'; import { ClientsModule } from '@nestjs/microservices'; import { AuthModule } from './services/auth/auth.module'; +import { CatalogModule } from './services/catalog/catalog.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { AuthModule } from './services/auth/auth.module'; natsClient('session'), ]), AuthModule, + CatalogModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/freeblox-web-service/src/filters/exception.filter.ts b/apps/freeblox-web-service/src/filters/exception.filter.ts index bcfe350..635c5c5 100644 --- a/apps/freeblox-web-service/src/filters/exception.filter.ts +++ b/apps/freeblox-web-service/src/filters/exception.filter.ts @@ -16,6 +16,7 @@ export class HttpExceptionFilter implements ExceptionFilter { const message = exception.message; response.status(status).json({ + error: exception.name, statusCode: status, message, timestamp: new Date().toISOString(), diff --git a/apps/freeblox-web-service/src/interceptors/catch-rpc-exception.interceptor.ts b/apps/freeblox-web-service/src/interceptors/catch-rpc-exception.interceptor.ts index e287009..372b7b3 100644 --- a/apps/freeblox-web-service/src/interceptors/catch-rpc-exception.interceptor.ts +++ b/apps/freeblox-web-service/src/interceptors/catch-rpc-exception.interceptor.ts @@ -17,7 +17,7 @@ export class CatchRpcExceptionInterceptor implements NestInterceptor { () => new HttpException( err.response, - err.status || HttpStatus.INTERNAL_SERVER_ERROR, + Number(err.status) || HttpStatus.INTERNAL_SERVER_ERROR, ), ); }), diff --git a/apps/freeblox-web-service/src/services/catalog/catalog.controller.ts b/apps/freeblox-web-service/src/services/catalog/catalog.controller.ts new file mode 100644 index 0000000..a8ba4f1 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/catalog.controller.ts @@ -0,0 +1,138 @@ +import { + Controller, + UseInterceptors, + ClassSerializerInterceptor, + Inject, + Get, + Post, + Body, + UploadedFiles, + UseGuards, + Patch, + Param, + ParseIntPipe, +} from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '../../decorators/user.decorator'; +import { UserInfo } from '@freeblox/shared'; +import { CreateContentDto } from './dtos/create-content.dto'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { AuthGuard } from '../../guards/auth.guard'; +import { CreateContentRevisionDto } from './dtos/create-content-revision.dto'; +import { ContentResponseDto } from './dtos/content-response.dto'; +import { ContentAssetType } from 'apps/catalog/src/enums/content-asset-type.enum'; +import { lastValueFrom } from 'rxjs'; + +@Controller({ + version: '1', + path: 'catalog', +}) +@ApiBearerAuth() +@ApiTags('Catalog') +@UseInterceptors(ClassSerializerInterceptor) +export class CatalogController { + constructor(@Inject('catalog') private catalog: ClientProxy) {} + + @Get('categories') + @ApiOperation({ summary: 'Get catalog categories' }) + async getCatalogCategories() { + return this.catalog.send('catalog.items.categories', {}); + } + + @Post('content') + @ApiOperation({ summary: 'Create new content' }) + @ApiOkResponse({ type: ContentResponseDto }) + @UseGuards(AuthGuard) + @UseInterceptors(AnyFilesInterceptor()) + async createContent( + @User() user: UserInfo, + @Body() body: CreateContentDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.catalog.send('catalog.items.create', { body, user, files }); + } + + @Get('content/:id') + @ApiOperation({ summary: 'Get content details' }) + @ApiOkResponse({ type: ContentResponseDto }) + @UseGuards(AuthGuard) + async getContent( + @Param('id', new ParseIntPipe()) id: number, + @User() user: UserInfo, + ) { + return this.catalog.send('catalog.items.byId', { id, user }); + } + + @Get('content/:id/thumbnail') + @ApiOperation({ summary: 'Get content thumbnail ID' }) + @ApiOkResponse({ type: ContentResponseDto }) + @UseGuards(AuthGuard) + async getContentThumbnail( + @Param('id', new ParseIntPipe()) id: number, + @User() user: UserInfo, + ) { + const [thumbnail] = await lastValueFrom( + this.catalog.send('catalog.assets.byId', { + id, + user, + type: [ContentAssetType.IMAGE], + typeName: ['thumbnail'], + }), + ); + return thumbnail; + } + + @Patch('content/:id') + @ApiOperation({ summary: 'Update content details' }) + @ApiOkResponse({ type: ContentResponseDto }) + @UseGuards(AuthGuard) + async updateContent( + @Param('id', new ParseIntPipe()) id: number, + @User() user: UserInfo, + @Body() body: CreateContentDto, + ) { + return this.catalog.send('catalog.items.update', { id, body, user }); + } + + @Post('content/:id/revision') + @ApiOperation({ + summary: 'Create a new revision (upload new content for item)', + }) + @UseGuards(AuthGuard) + @UseInterceptors(AnyFilesInterceptor()) + async createContentRevision( + @Param('id', new ParseIntPipe()) id: number, + @User() user: UserInfo, + @Body() body: CreateContentRevisionDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.catalog.send('catalog.items.createRevision', { + id, + body, + user, + files, + }); + } + + @Patch('content/:id/publish') + @ApiOkResponse({ type: ContentResponseDto }) + @ApiOperation({ + summary: 'Publish content', + }) + @UseGuards(AuthGuard) + async publishContent( + @Param('id', new ParseIntPipe()) id: number, + @User() user: UserInfo, + ) { + return this.catalog.send('catalog.items.publish', { + id, + user, + }); + } +} diff --git a/apps/freeblox-web-service/src/services/catalog/catalog.module.ts b/apps/freeblox-web-service/src/services/catalog/catalog.module.ts new file mode 100644 index 0000000..80bd304 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/catalog.module.ts @@ -0,0 +1,12 @@ +import { natsClient } from '@freeblox/shared'; +import { Module } from '@nestjs/common'; +import { ClientsModule } from '@nestjs/microservices'; +import { CatalogController } from './catalog.controller'; + +@Module({ + imports: [ + ClientsModule.register([natsClient('catalog'), natsClient('auth')]), + ], + controllers: [CatalogController], +}) +export class CatalogModule {} diff --git a/apps/freeblox-web-service/src/services/catalog/dtos/content-response.dto.ts b/apps/freeblox-web-service/src/services/catalog/dtos/content-response.dto.ts new file mode 100644 index 0000000..4bccd90 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/dtos/content-response.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserResponseDto } from './user-response.dto'; +import { Currency } from 'apps/catalog/src/enums/currency.enum'; + +export class ContentPriceResponseDto { + @ApiProperty() + id: number; + + @ApiProperty() + contentId: number; + + @ApiProperty() + price: number; + + @ApiProperty({ enum: Currency }) + currency: string; +} + +export class ContentResponseDto { + @ApiProperty() + id: number; + + @ApiProperty() + name: string; + + @ApiProperty() + description: string; + + @ApiProperty() + userId: string; + + @ApiProperty() + parentId: number | null; + + @ApiProperty() + restricted: boolean; + + @ApiProperty() + onsale: boolean; + + @ApiProperty() + published: boolean; + + @ApiProperty() + commentsEnabled: boolean; + + @ApiProperty() + openSource: boolean; + + @ApiProperty() + privacy: string; + + @ApiProperty() + type: string; + + @ApiProperty() + stock: number | null; + + @ApiProperty() + license: string; + + @ApiProperty({ type: ContentPriceResponseDto, isArray: true }) + prices: ContentPriceResponseDto[]; + + @ApiProperty({ type: UserResponseDto }) + user: UserResponseDto; +} diff --git a/apps/freeblox-web-service/src/services/catalog/dtos/create-content-revision.dto.ts b/apps/freeblox-web-service/src/services/catalog/dtos/create-content-revision.dto.ts new file mode 100644 index 0000000..926d893 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/dtos/create-content-revision.dto.ts @@ -0,0 +1,8 @@ +import { CreateContentRevisionRequest } from 'apps/catalog/src/interfaces/create-content-request.interface'; +import { CreateContentFilesDto } from './create-content.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateContentRevisionDto implements CreateContentRevisionRequest { + @ApiProperty({ type: CreateContentFilesDto }) + files?: CreateContentFilesDto; +} diff --git a/apps/freeblox-web-service/src/services/catalog/dtos/create-content.dto.ts b/apps/freeblox-web-service/src/services/catalog/dtos/create-content.dto.ts new file mode 100644 index 0000000..f99e413 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/dtos/create-content.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentType } from 'apps/catalog/src/enums/content-type.enum'; +import { Currency } from 'apps/catalog/src/enums/currency.enum'; +import { Privacy } from 'apps/catalog/src/enums/privacy.enum'; +import { + ContentAssetTypeProperties, + CreateContentFiles, + CreateContentPricing, + CreateContentRequest, +} from 'apps/catalog/src/interfaces/create-content-request.interface'; +import { Type } from 'class-transformer'; + +export class CreateContentFilesDto implements CreateContentFiles { + [x: string]: ContentAssetTypeProperties; +} + +export class CreateContentPricingDto implements CreateContentPricing { + @ApiProperty({ type: String, enum: Currency }) + currency: Currency; + + @ApiProperty() + price: number; +} + +export class CreateContentDto implements CreateContentRequest { + @ApiProperty() + name: string; + + @ApiProperty() + description: string; + + @ApiProperty({ enum: Privacy }) + privacy: Privacy; + + @ApiProperty({ enum: ContentType }) + type: ContentType; + + @ApiProperty({ nullable: true }) + parentId?: number; + + @ApiProperty({ nullable: true }) + onsale?: boolean; + + @ApiProperty({ nullable: true }) + commentsEnabled?: boolean; + + @ApiProperty({ nullable: true }) + openSource?: boolean; + + @ApiProperty({ nullable: true }) + stock?: number; + + @ApiProperty({ nullable: true }) + license?: string; + + @ApiProperty({ nullable: true, type: CreateContentFilesDto }) + @Type(() => CreateContentFilesDto) + files?: CreateContentFilesDto; + + @ApiProperty({ nullable: true, type: CreateContentPricingDto, isArray: true }) + @Type(() => CreateContentPricingDto) + prices?: CreateContentPricingDto[]; +} diff --git a/apps/freeblox-web-service/src/services/catalog/dtos/update-content.dto.ts b/apps/freeblox-web-service/src/services/catalog/dtos/update-content.dto.ts new file mode 100644 index 0000000..5c949b2 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/dtos/update-content.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContentDto } from './create-content.dto'; + +export class UpdateContentDto extends PartialType(CreateContentDto) {} diff --git a/apps/freeblox-web-service/src/services/catalog/dtos/user-response.dto.ts b/apps/freeblox-web-service/src/services/catalog/dtos/user-response.dto.ts new file mode 100644 index 0000000..2a66aa9 --- /dev/null +++ b/apps/freeblox-web-service/src/services/catalog/dtos/user-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + username: string; + + @ApiProperty() + display_name: string; +} diff --git a/libs/shared/src/dtos/pagination.dto.ts b/libs/shared/src/dtos/pagination.dto.ts new file mode 100644 index 0000000..637b2c4 --- /dev/null +++ b/libs/shared/src/dtos/pagination.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +@Expose() +export class PaginationDto { + @ApiProperty() + total: number; + + @ApiProperty() + count: number; + + @ApiProperty() + page: number; + + @ApiProperty() + pageSize: number; +} diff --git a/libs/shared/src/exception/rpc.exception.ts b/libs/shared/src/exception/rpc.exception.ts index 4c844dd..2d3c72f 100644 --- a/libs/shared/src/exception/rpc.exception.ts +++ b/libs/shared/src/exception/rpc.exception.ts @@ -1,6 +1,7 @@ import { HttpStatus } from '@nestjs/common'; export class HttpRpcException { + public name = 'HttpRpcException'; isRpcException = true; constructor( @@ -25,31 +26,43 @@ export class HttpRpcException { } export class BadRequestRpcException extends HttpRpcException { + public name = 'BadRequestRpcException'; constructor(message: string | object) { super(message, HttpStatus.BAD_REQUEST); } } export class NotFoundRpcException extends HttpRpcException { + public name = 'NotFoundRpcException'; constructor(message: string | object) { super(message, HttpStatus.NOT_FOUND); } } export class ForbiddenRpcException extends HttpRpcException { + public name = 'ForbiddenRpcException'; constructor(message: string | object) { super(message, HttpStatus.FORBIDDEN); } } export class UnauthorizedRpcException extends HttpRpcException { + public name = 'UnauthorizedRpcException'; constructor(message: string | object) { super(message, HttpStatus.UNAUTHORIZED); } } export class PreconditionFailedRpcException extends HttpRpcException { + public name = 'PreconditionFailedRpcException'; constructor(message: string | object) { super(message, HttpStatus.PRECONDITION_FAILED); } } + +export class ValidationRpcException extends BadRequestRpcException { + public name = 'ValidationRpcException'; + constructor(message: string | object = 'Validation failed') { + super(message); + } +} diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index 4cfc539..67b5916 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -2,10 +2,12 @@ export * from './shared.module'; export * from './shared.service'; export * from './utils/nats-client'; export * from './utils/tokens'; +export * from './utils/parse-boolean'; export * from './database/make-typeorm'; export * from './database/make-knex'; export * from './database/metaentity'; export * from './types/user-token.enum'; export * from './types/userinfo'; +export * from './types/page-query.interface'; export * from './exception/rpc.exception'; export * from './filters/rpc-exception.filter'; diff --git a/libs/shared/src/types/page-query.interface.ts b/libs/shared/src/types/page-query.interface.ts new file mode 100644 index 0000000..523af99 --- /dev/null +++ b/libs/shared/src/types/page-query.interface.ts @@ -0,0 +1,4 @@ +export interface PageQuery { + page?: number; + pageSize?: number; +} diff --git a/libs/shared/src/utils/parse-boolean.ts b/libs/shared/src/utils/parse-boolean.ts new file mode 100644 index 0000000..aedc2be --- /dev/null +++ b/libs/shared/src/utils/parse-boolean.ts @@ -0,0 +1,5 @@ +export const parseBoolean = (input: unknown) => { + if (typeof input === 'boolean') return input; + if (!input) return undefined; + return input === 'true'; +}; diff --git a/package.json b/package.json index 987d9ac..82cbe6b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "jose": "^4.14.4", "jsonwebtoken": "^9.0.0", "knex": "^2.4.2", + "multer": "1.4.5-lts.1", "nats": "^2.15.1", "otplib": "^12.0.1", "pg": "^8.11.1", @@ -52,6 +53,7 @@ "@types/bcrypt": "^5.0.0", "@types/express": "^4.17.17", "@types/jest": "29.5.2", + "@types/multer": "^1.4.7", "@types/node": "^20.3.2", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.60.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c69c15..adb7d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ dependencies: knex: specifier: ^2.4.2 version: 2.4.2(pg@8.11.1) + multer: + specifier: 1.4.5-lts.1 + version: 1.4.5-lts.1 nats: specifier: ^2.15.1 version: 2.15.1 @@ -94,6 +97,9 @@ devDependencies: '@types/jest': specifier: 29.5.2 version: 29.5.2 + '@types/multer': + specifier: ^1.4.7 + version: 1.4.7 '@types/node': specifier: ^20.3.2 version: 20.3.2 @@ -1446,6 +1452,12 @@ packages: resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} dev: true + /@types/multer@1.4.7: + resolution: {integrity: sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==} + dependencies: + '@types/express': 4.17.17 + dev: true + /@types/node@20.3.2: resolution: {integrity: sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==} @@ -4391,6 +4403,19 @@ packages: type-is: 1.6.18 xtend: 4.0.2 + /multer@1.4.5-lts.1: + resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} + engines: {node: '>= 6.0.0'} + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + dev: false + /mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: true