content creation, update, publishing
This commit is contained in:
parent
42d4c4e40c
commit
878687e7ec
@ -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;
|
||||
|
@ -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<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 { 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,19 +19,13 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'development',
|
||||
load: [makeKnex('catalog', __dirname), makeTypeOrm('catalog')],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
||||
}),
|
||||
TypeOrmModule.forFeature([
|
||||
const entities = [
|
||||
ContentEntity,
|
||||
ContentRevisionEntity,
|
||||
ContentModerationEntity,
|
||||
@ -43,7 +37,25 @@ import { ContentReportEntity } from './database/entities/content-report.entity';
|
||||
ContentFavoriteEntity,
|
||||
ContentVoteEntity,
|
||||
ContentReportEntity,
|
||||
]),
|
||||
ContentCategoryEntity,
|
||||
ContentCategoryContentTypeEntity,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'development',
|
||||
load: [makeKnex('catalog', __dirname), makeTypeOrm('catalog')],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
...config.get('typeorm'),
|
||||
entities,
|
||||
}),
|
||||
}),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -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' })
|
||||
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' })
|
||||
|
@ -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')
|
||||
export class ContentModerationBanEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Expose()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: true, name: 'ban_id' })
|
||||
banId: number;
|
||||
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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<UserEntity>;
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
.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
|
||||
|
@ -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',
|
||||
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',
|
||||
|
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 { CatalogModule } from './catalog.module';
|
||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||
import { HttpRpcExceptionFilter } from '@freeblox/shared';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
||||
@ -13,6 +14,7 @@ async function bootstrap() {
|
||||
},
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new HttpRpcExceptionFilter());
|
||||
await app.listen();
|
||||
}
|
||||
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 { 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],
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
@ -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';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
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",
|
||||
"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",
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user