content creation, update, publishing

This commit is contained in:
Evert Prants 2023-07-22 18:34:12 +03:00
parent 42d4c4e40c
commit 878687e7ec
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
38 changed files with 1736 additions and 37 deletions

View File

@ -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;

View File

@ -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);
}
}

View File

@ -1,6 +1,6 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
import { CatalogService } from './services/catalog.service';
import { ClientsModule } from '@nestjs/microservices';
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
import { ConfigModule, ConfigService } from '@nestjs/config';
@ -19,6 +19,27 @@ import { ContentTradeEntity } from './database/entities/content-trade.entity';
import { ContentFavoriteEntity } from './database/entities/content-favorite.entity';
import { ContentVoteEntity } from './database/entities/content-vote.entity';
import { ContentReportEntity } from './database/entities/content-report.entity';
import {
ContentCategoryContentTypeEntity,
ContentCategoryEntity,
} from './database/entities/content-category.entity';
import { CreateContentService } from './services/create-content.service';
const entities = [
ContentEntity,
ContentRevisionEntity,
ContentModerationEntity,
ContentModerationBanEntity,
ContentAssetEntity,
ContentPriceEntity,
ContentOwnershipEntity,
ContentTradeEntity,
ContentFavoriteEntity,
ContentVoteEntity,
ContentReportEntity,
ContentCategoryEntity,
ContentCategoryContentTypeEntity,
];
@Module({
imports: [
@ -29,21 +50,12 @@ import { ContentReportEntity } from './database/entities/content-report.entity';
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => config.get('typeorm'),
useFactory: (config: ConfigService) => ({
...config.get('typeorm'),
entities,
}),
}),
TypeOrmModule.forFeature([
ContentEntity,
ContentRevisionEntity,
ContentModerationEntity,
ContentModerationBanEntity,
ContentAssetEntity,
ContentPriceEntity,
ContentOwnershipEntity,
ContentTradeEntity,
ContentFavoriteEntity,
ContentVoteEntity,
ContentReportEntity,
]),
TypeOrmModule.forFeature(entities),
ClientsModule.register([
natsClient('catalog'),
natsClient('auth'),
@ -51,7 +63,7 @@ import { ContentReportEntity } from './database/entities/content-report.entity';
]),
],
controllers: [CatalogController],
providers: [CatalogService],
providers: [CatalogService, CreateContentService],
})
export class CatalogModule implements OnModuleInit {
constructor(private readonly config: ConfigService) {}
@ -59,6 +71,6 @@ export class CatalogModule implements OnModuleInit {
async onModuleInit() {
const knexInstance = knex(this.config.get('knex'));
await knexInstance.migrate.latest();
// await knexInstance.seed.run();
await knexInstance.seed.run();
}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class CatalogService {
getHello(): string {
return 'Hello World!';
}
}

View 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',
];

View File

@ -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' })

View File

@ -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;
}

View File

@ -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;

View File

@ -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[];
}

View File

@ -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>;
}

View File

@ -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

View File

@ -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'),
]);
}

View 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,
});
}
}
}

View File

@ -0,0 +1,13 @@
import { Exclude, Expose } from 'class-transformer';
@Exclude()
export class ContentUserDto {
@Expose()
id: string;
@Expose()
username: string;
@Expose()
displayName: string;
}

View File

@ -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',

View File

@ -0,0 +1,8 @@
export enum OrderBy {
DEFAULT = 'default',
POPULAR = 'popular',
NEWEST = 'newest',
OLDEST = 'oldest',
PRICE_LOW = 'price-low',
PRICE_HIGH = 'price-high',
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();

View 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;
}
}

View 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`,
);
}
}
}

View File

@ -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],

View File

@ -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(),

View File

@ -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,
),
);
}),

View File

@ -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,
});
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContentDto } from './create-content.dto';
export class UpdateContentDto extends PartialType(CreateContentDto) {}

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
username: string;
@ApiProperty()
display_name: string;
}

View 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;
}

View File

@ -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);
}
}

View File

@ -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';

View File

@ -0,0 +1,4 @@
export interface PageQuery {
page?: number;
pageSize?: number;
}

View File

@ -0,0 +1,5 @@
export const parseBoolean = (input: unknown) => {
if (typeof input === 'boolean') return input;
if (!input) return undefined;
return input === 'true';
};

View File

@ -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",

View File

@ -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