content creation, update, publishing
This commit is contained in:
parent
42d4c4e40c
commit
878687e7ec
@ -1,6 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { CatalogController } from './catalog.controller';
|
import { CatalogController } from './catalog.controller';
|
||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './services/catalog.service';
|
||||||
|
|
||||||
describe('CatalogController', () => {
|
describe('CatalogController', () => {
|
||||||
let catalogController: CatalogController;
|
let catalogController: CatalogController;
|
||||||
|
@ -1,12 +1,116 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
import { CatalogService } from './catalog.service';
|
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()
|
@Controller()
|
||||||
export class CatalogController {
|
export class CatalogController {
|
||||||
constructor(private readonly catalogService: CatalogService) {}
|
constructor(
|
||||||
|
private readonly catalogService: CatalogService,
|
||||||
|
private readonly createContentService: CreateContentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
// Getters
|
||||||
getHello(): string {
|
|
||||||
return this.catalogService.getHello();
|
@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<CreateContentRequest>;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Module, OnModuleInit } from '@nestjs/common';
|
import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
import { CatalogController } from './catalog.controller';
|
import { CatalogController } from './catalog.controller';
|
||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './services/catalog.service';
|
||||||
import { ClientsModule } from '@nestjs/microservices';
|
import { ClientsModule } from '@nestjs/microservices';
|
||||||
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
|
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
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 { ContentFavoriteEntity } from './database/entities/content-favorite.entity';
|
||||||
import { ContentVoteEntity } from './database/entities/content-vote.entity';
|
import { ContentVoteEntity } from './database/entities/content-vote.entity';
|
||||||
import { ContentReportEntity } from './database/entities/content-report.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -29,21 +50,12 @@ import { ContentReportEntity } from './database/entities/content-report.entity';
|
|||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
useFactory: (config: ConfigService) => ({
|
||||||
|
...config.get('typeorm'),
|
||||||
|
entities,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature(entities),
|
||||||
ContentEntity,
|
|
||||||
ContentRevisionEntity,
|
|
||||||
ContentModerationEntity,
|
|
||||||
ContentModerationBanEntity,
|
|
||||||
ContentAssetEntity,
|
|
||||||
ContentPriceEntity,
|
|
||||||
ContentOwnershipEntity,
|
|
||||||
ContentTradeEntity,
|
|
||||||
ContentFavoriteEntity,
|
|
||||||
ContentVoteEntity,
|
|
||||||
ContentReportEntity,
|
|
||||||
]),
|
|
||||||
ClientsModule.register([
|
ClientsModule.register([
|
||||||
natsClient('catalog'),
|
natsClient('catalog'),
|
||||||
natsClient('auth'),
|
natsClient('auth'),
|
||||||
@ -51,7 +63,7 @@ import { ContentReportEntity } from './database/entities/content-report.entity';
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [CatalogController],
|
controllers: [CatalogController],
|
||||||
providers: [CatalogService],
|
providers: [CatalogService, CreateContentService],
|
||||||
})
|
})
|
||||||
export class CatalogModule implements OnModuleInit {
|
export class CatalogModule implements OnModuleInit {
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(private readonly config: ConfigService) {}
|
||||||
@ -59,6 +71,6 @@ export class CatalogModule implements OnModuleInit {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const knexInstance = knex(this.config.get('knex'));
|
const knexInstance = knex(this.config.get('knex'));
|
||||||
await knexInstance.migrate.latest();
|
await knexInstance.migrate.latest();
|
||||||
// await knexInstance.seed.run();
|
await knexInstance.seed.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CatalogService {
|
|
||||||
getHello(): string {
|
|
||||||
return 'Hello World!';
|
|
||||||
}
|
|
||||||
}
|
|
53
apps/catalog/src/constants/constants.ts
Normal file
53
apps/catalog/src/constants/constants.ts
Normal file
@ -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',
|
||||||
|
];
|
@ -25,13 +25,13 @@ export class ContentAssetEntity {
|
|||||||
@Column({ name: 'asset_id', type: 'uuid' })
|
@Column({ name: 'asset_id', type: 'uuid' })
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
|
||||||
@Column({ type: 'string', enum: ContentAssetType })
|
@Column({ type: String, enum: ContentAssetType })
|
||||||
type: ContentAssetType;
|
type: ContentAssetType;
|
||||||
|
|
||||||
@Column({ name: 'type_name', nullable: true })
|
@Column({ name: 'type_name', nullable: true })
|
||||||
typeName?: string;
|
typeName?: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ default: 0 })
|
||||||
index: number;
|
index: number;
|
||||||
|
|
||||||
@ManyToOne(() => ContentEntity, { onDelete: 'CASCADE' })
|
@ManyToOne(() => ContentEntity, { onDelete: 'CASCADE' })
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -94,6 +94,10 @@ export class ContentModerationEntity extends MetaEntity {
|
|||||||
|
|
||||||
@Entity('content_moderation_ban')
|
@Entity('content_moderation_ban')
|
||||||
export class ContentModerationBanEntity {
|
export class ContentModerationBanEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Expose()
|
||||||
|
id: number;
|
||||||
|
|
||||||
@Column({ nullable: true, name: 'ban_id' })
|
@Column({ nullable: true, name: 'ban_id' })
|
||||||
banId: number;
|
banId: number;
|
||||||
|
|
||||||
|
@ -7,9 +7,11 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ContentEntity } from './content.entity';
|
import { ContentEntity } from './content.entity';
|
||||||
|
import { ContentAssetEntity } from './content-asset.entity';
|
||||||
|
|
||||||
@Entity('content_revision')
|
@Entity('content_revision')
|
||||||
@Exclude()
|
@Exclude()
|
||||||
@ -32,4 +34,7 @@ export class ContentRevisionEntity extends UserMetaEntity {
|
|||||||
@ManyToOne(() => ContentEntity, { onDelete: 'CASCADE' })
|
@ManyToOne(() => ContentEntity, { onDelete: 'CASCADE' })
|
||||||
@JoinColumn({ name: 'content_id' })
|
@JoinColumn({ name: 'content_id' })
|
||||||
content: ContentEntity;
|
content: ContentEntity;
|
||||||
|
|
||||||
|
@OneToMany(() => ContentAssetEntity, (asset) => asset.revision)
|
||||||
|
assets: ContentAssetEntity[];
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
Index,
|
Index,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Privacy } from '../../enums/privacy.enum';
|
import { Privacy } from '../../enums/privacy.enum';
|
||||||
@ -22,6 +23,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { ContentPriceEntity } from './content-price.entity';
|
||||||
|
|
||||||
@Entity('content')
|
@Entity('content')
|
||||||
@Expose()
|
@Expose()
|
||||||
@ -77,7 +79,7 @@ export class ContentEntity extends UserMetaEntity {
|
|||||||
@IsEnum(Privacy)
|
@IsEnum(Privacy)
|
||||||
privacy: Privacy;
|
privacy: Privacy;
|
||||||
|
|
||||||
@Column({ type: 'string', enum: ContentType })
|
@Column({ type: String, enum: ContentType })
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
@Index()
|
@Index()
|
||||||
type: ContentType;
|
type: ContentType;
|
||||||
@ -105,5 +107,9 @@ export class ContentEntity extends UserMetaEntity {
|
|||||||
@Exclude()
|
@Exclude()
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
|
|
||||||
user?: UserEntity;
|
@OneToMany(() => ContentPriceEntity, (price) => price.content)
|
||||||
|
@Expose()
|
||||||
|
prices?: ContentPriceEntity[];
|
||||||
|
|
||||||
|
user?: Partial<UserEntity>;
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,7 @@ export async function up(knex: Knex): Promise<void> {
|
|||||||
.onDelete('CASCADE');
|
.onDelete('CASCADE');
|
||||||
}),
|
}),
|
||||||
knex.schema.createTable('content_moderation_ban', (table) => {
|
knex.schema.createTable('content_moderation_ban', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
table.integer('content_moderation_id').unsigned().notNullable();
|
table.integer('content_moderation_id').unsigned().notNullable();
|
||||||
table.integer('ban_id').unsigned().nullable();
|
table.integer('ban_id').unsigned().nullable();
|
||||||
table
|
table
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
Promise.all([
|
||||||
|
knex.schema.dropTable('content_category'),
|
||||||
|
knex.schema.dropTable('content_category_content_type'),
|
||||||
|
]);
|
||||||
|
}
|
200
apps/catalog/src/database/seeds/0001-initial-categories.ts
Normal file
200
apps/catalog/src/database/seeds/0001-initial-categories.ts
Normal file
@ -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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/catalog/src/dtos/content-user.dto.ts
Normal file
13
apps/catalog/src/dtos/content-user.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
|
||||||
|
@Exclude()
|
||||||
|
export class ContentUserDto {
|
||||||
|
@Expose()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
displayName: string;
|
||||||
|
}
|
@ -3,9 +3,14 @@ export enum ContentType {
|
|||||||
CHARACTER = 'character',
|
CHARACTER = 'character',
|
||||||
HAT = 'hat',
|
HAT = 'hat',
|
||||||
ACCESSORY = 'accessory',
|
ACCESSORY = 'accessory',
|
||||||
|
FACE = 'face',
|
||||||
FRONT = 'front',
|
FRONT = 'front',
|
||||||
BACK = 'back',
|
BACK = 'back',
|
||||||
|
BODY = 'body',
|
||||||
TOOL = 'tool',
|
TOOL = 'tool',
|
||||||
|
TSHIRT = 'tshirt',
|
||||||
|
SHIRT = 'shirt',
|
||||||
|
PANTS = 'pants',
|
||||||
MESH = 'mesh',
|
MESH = 'mesh',
|
||||||
TEXTURE = 'texture',
|
TEXTURE = 'texture',
|
||||||
GAMEOBJECT = 'gameobject',
|
GAMEOBJECT = 'gameobject',
|
||||||
|
8
apps/catalog/src/enums/order-by.enum.ts
Normal file
8
apps/catalog/src/enums/order-by.enum.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export enum OrderBy {
|
||||||
|
DEFAULT = 'default',
|
||||||
|
POPULAR = 'popular',
|
||||||
|
NEWEST = 'newest',
|
||||||
|
OLDEST = 'oldest',
|
||||||
|
PRICE_LOW = 'price-low',
|
||||||
|
PRICE_HIGH = 'price-high',
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { CatalogModule } from './catalog.module';
|
import { CatalogModule } from './catalog.module';
|
||||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||||
|
import { HttpRpcExceptionFilter } from '@freeblox/shared';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
||||||
@ -13,6 +14,7 @@ async function bootstrap() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new HttpRpcExceptionFilter());
|
||||||
await app.listen();
|
await app.listen();
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
216
apps/catalog/src/services/catalog.service.ts
Normal file
216
apps/catalog/src/services/catalog.service.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
555
apps/catalog/src/services/create-content.service.ts
Normal file
555
apps/catalog/src/services/create-content.service.ts
Normal file
@ -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<CreateContentRequest>,
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { AppService } from './app.service';
|
|||||||
import { natsClient } from '@freeblox/shared';
|
import { natsClient } from '@freeblox/shared';
|
||||||
import { ClientsModule } from '@nestjs/microservices';
|
import { ClientsModule } from '@nestjs/microservices';
|
||||||
import { AuthModule } from './services/auth/auth.module';
|
import { AuthModule } from './services/auth/auth.module';
|
||||||
|
import { CatalogModule } from './services/catalog/catalog.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -17,6 +18,7 @@ import { AuthModule } from './services/auth/auth.module';
|
|||||||
natsClient('session'),
|
natsClient('session'),
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
CatalogModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
@ -16,6 +16,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
const message = exception.message;
|
const message = exception.message;
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
|
error: exception.name,
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
message,
|
message,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
@ -17,7 +17,7 @@ export class CatchRpcExceptionInterceptor implements NestInterceptor {
|
|||||||
() =>
|
() =>
|
||||||
new HttpException(
|
new HttpException(
|
||||||
err.response,
|
err.response,
|
||||||
err.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
Number(err.status) || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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 {}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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[];
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateContentDto } from './create-content.dto';
|
||||||
|
|
||||||
|
export class UpdateContentDto extends PartialType(CreateContentDto) {}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UserResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
display_name: string;
|
||||||
|
}
|
17
libs/shared/src/dtos/pagination.dto.ts
Normal file
17
libs/shared/src/dtos/pagination.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
export class HttpRpcException {
|
export class HttpRpcException {
|
||||||
|
public name = 'HttpRpcException';
|
||||||
isRpcException = true;
|
isRpcException = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -25,31 +26,43 @@ export class HttpRpcException {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BadRequestRpcException extends HttpRpcException {
|
export class BadRequestRpcException extends HttpRpcException {
|
||||||
|
public name = 'BadRequestRpcException';
|
||||||
constructor(message: string | object) {
|
constructor(message: string | object) {
|
||||||
super(message, HttpStatus.BAD_REQUEST);
|
super(message, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotFoundRpcException extends HttpRpcException {
|
export class NotFoundRpcException extends HttpRpcException {
|
||||||
|
public name = 'NotFoundRpcException';
|
||||||
constructor(message: string | object) {
|
constructor(message: string | object) {
|
||||||
super(message, HttpStatus.NOT_FOUND);
|
super(message, HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ForbiddenRpcException extends HttpRpcException {
|
export class ForbiddenRpcException extends HttpRpcException {
|
||||||
|
public name = 'ForbiddenRpcException';
|
||||||
constructor(message: string | object) {
|
constructor(message: string | object) {
|
||||||
super(message, HttpStatus.FORBIDDEN);
|
super(message, HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnauthorizedRpcException extends HttpRpcException {
|
export class UnauthorizedRpcException extends HttpRpcException {
|
||||||
|
public name = 'UnauthorizedRpcException';
|
||||||
constructor(message: string | object) {
|
constructor(message: string | object) {
|
||||||
super(message, HttpStatus.UNAUTHORIZED);
|
super(message, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PreconditionFailedRpcException extends HttpRpcException {
|
export class PreconditionFailedRpcException extends HttpRpcException {
|
||||||
|
public name = 'PreconditionFailedRpcException';
|
||||||
constructor(message: string | object) {
|
constructor(message: string | object) {
|
||||||
super(message, HttpStatus.PRECONDITION_FAILED);
|
super(message, HttpStatus.PRECONDITION_FAILED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ValidationRpcException extends BadRequestRpcException {
|
||||||
|
public name = 'ValidationRpcException';
|
||||||
|
constructor(message: string | object = 'Validation failed') {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,10 +2,12 @@ export * from './shared.module';
|
|||||||
export * from './shared.service';
|
export * from './shared.service';
|
||||||
export * from './utils/nats-client';
|
export * from './utils/nats-client';
|
||||||
export * from './utils/tokens';
|
export * from './utils/tokens';
|
||||||
|
export * from './utils/parse-boolean';
|
||||||
export * from './database/make-typeorm';
|
export * from './database/make-typeorm';
|
||||||
export * from './database/make-knex';
|
export * from './database/make-knex';
|
||||||
export * from './database/metaentity';
|
export * from './database/metaentity';
|
||||||
export * from './types/user-token.enum';
|
export * from './types/user-token.enum';
|
||||||
export * from './types/userinfo';
|
export * from './types/userinfo';
|
||||||
|
export * from './types/page-query.interface';
|
||||||
export * from './exception/rpc.exception';
|
export * from './exception/rpc.exception';
|
||||||
export * from './filters/rpc-exception.filter';
|
export * from './filters/rpc-exception.filter';
|
||||||
|
4
libs/shared/src/types/page-query.interface.ts
Normal file
4
libs/shared/src/types/page-query.interface.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface PageQuery {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
5
libs/shared/src/utils/parse-boolean.ts
Normal file
5
libs/shared/src/utils/parse-boolean.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const parseBoolean = (input: unknown) => {
|
||||||
|
if (typeof input === 'boolean') return input;
|
||||||
|
if (!input) return undefined;
|
||||||
|
return input === 'true';
|
||||||
|
};
|
@ -36,6 +36,7 @@
|
|||||||
"jose": "^4.14.4",
|
"jose": "^4.14.4",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"knex": "^2.4.2",
|
"knex": "^2.4.2",
|
||||||
|
"multer": "1.4.5-lts.1",
|
||||||
"nats": "^2.15.1",
|
"nats": "^2.15.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"pg": "^8.11.1",
|
"pg": "^8.11.1",
|
||||||
@ -52,6 +53,7 @@
|
|||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "29.5.2",
|
"@types/jest": "29.5.2",
|
||||||
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^20.3.2",
|
"@types/node": "^20.3.2",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.60.1",
|
"@typescript-eslint/eslint-plugin": "^5.60.1",
|
||||||
|
@ -50,6 +50,9 @@ dependencies:
|
|||||||
knex:
|
knex:
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.4.2(pg@8.11.1)
|
version: 2.4.2(pg@8.11.1)
|
||||||
|
multer:
|
||||||
|
specifier: 1.4.5-lts.1
|
||||||
|
version: 1.4.5-lts.1
|
||||||
nats:
|
nats:
|
||||||
specifier: ^2.15.1
|
specifier: ^2.15.1
|
||||||
version: 2.15.1
|
version: 2.15.1
|
||||||
@ -94,6 +97,9 @@ devDependencies:
|
|||||||
'@types/jest':
|
'@types/jest':
|
||||||
specifier: 29.5.2
|
specifier: 29.5.2
|
||||||
version: 29.5.2
|
version: 29.5.2
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.3.2
|
specifier: ^20.3.2
|
||||||
version: 20.3.2
|
version: 20.3.2
|
||||||
@ -1446,6 +1452,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
||||||
dev: true
|
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:
|
/@types/node@20.3.2:
|
||||||
resolution: {integrity: sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==}
|
resolution: {integrity: sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==}
|
||||||
|
|
||||||
@ -4391,6 +4403,19 @@ packages:
|
|||||||
type-is: 1.6.18
|
type-is: 1.6.18
|
||||||
xtend: 4.0.2
|
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:
|
/mute-stream@0.0.8:
|
||||||
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
|
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
Loading…
Reference in New Issue
Block a user