diff --git a/config/typeorm.config.ts b/config/typeorm.config.ts index e8db495..ffb0f43 100644 --- a/config/typeorm.config.ts +++ b/config/typeorm.config.ts @@ -13,6 +13,7 @@ export const typeOrmConfig: TypeOrmConfig = (configService: ConfigService) => { username: configService.get('JAMIE_API_DATABASE_USER'), password: configService.get('JAMIE_API_DATABASE_PASSWORD'), database: configService.get('JAMIE_API_DATABASE_NAME'), + timezone: 'Z', entities: [join(__dirname, '..', 'src', '**', '*.entity{.js,.ts}')], subscribers: [join(__dirname, '..', 'src', '**', '*.subscriber{.js,.ts}')], migrationsRun: true, diff --git a/docker-compose.yml b/docker-compose.yml index a46993d..a708d24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: MYSQL_DATABASE: 'api' MYSQL_USER: 'api' MYSQL_PASSWORD: 'api' + TZ: '+00:00' + command: --default-time-zone=+00:00 prometheus: image: bitnami/prometheus diff --git a/migrations.config.ts b/migrations.config.ts index 5c83e39..d6ce914 100644 --- a/migrations.config.ts +++ b/migrations.config.ts @@ -15,6 +15,7 @@ export default new DataSource({ username: configService.get('JAMIE_API_DATABASE_USER'), password: configService.get('JAMIE_API_DATABASE_PASSWORD'), database: configService.get('JAMIE_API_DATABASE_NAME'), + timezone: 'Z', entities: [join(__dirname, 'dist', 'src', '**', '*.entity.js')], migrations: [join(__dirname, 'dist', 'migrations', '*.js')], namingStrategy: new SnakeNamingStrategy(), diff --git a/migrations/1677257893669-CreateMenuRevisions.ts b/migrations/1677257893669-CreateMenuRevisions.ts new file mode 100644 index 0000000..909006a --- /dev/null +++ b/migrations/1677257893669-CreateMenuRevisions.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMenuRevisions1677257893669 implements MigrationInterface { + name = 'CreateMenuRevisions1677257893669'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`menu_revisions\` (\`id\` int NOT NULL, \`menu_id\` int NOT NULL, \`description\` text NOT NULL, \`snapshot\` text NOT NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`, \`menu_id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_revisions\` ADD CONSTRAINT \`FK_ae57a5041c46e5dba5810a73030\` FOREIGN KEY (\`menu_id\`) REFERENCES \`menus\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menu_revisions\` DROP FOREIGN KEY \`FK_ae57a5041c46e5dba5810a73030\``, + ); + await queryRunner.query(`DROP TABLE \`menu_revisions\``); + } +} diff --git a/migrations/1677272600645-AddCurrentRevisionToMenu.ts b/migrations/1677272600645-AddCurrentRevisionToMenu.ts new file mode 100644 index 0000000..eb76293 --- /dev/null +++ b/migrations/1677272600645-AddCurrentRevisionToMenu.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCurrentRevisionToMenu1677272600645 + implements MigrationInterface +{ + name = 'AddCurrentRevisionToMenu1677272600645'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`current_revision_id\` int NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`current_revision_menu_id\` int NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD CONSTRAINT \`FK_27681781e1b6b13225c733fb648\` FOREIGN KEY (\`current_revision_id\`, \`current_revision_menu_id\`) REFERENCES \`menu_revisions\`(\`id\`,\`menu_id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menus\` DROP FOREIGN KEY \`FK_27681781e1b6b13225c733fb648\``, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` DROP COLUMN \`current_revision_menu_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` DROP COLUMN \`current_revision_id\``, + ); + } +} diff --git a/migrations/1677279793975-MenuItemCompositePrimaryKey.ts b/migrations/1677279793975-MenuItemCompositePrimaryKey.ts new file mode 100644 index 0000000..ed0a601 --- /dev/null +++ b/migrations/1677279793975-MenuItemCompositePrimaryKey.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MenuItemCompositePrimaryKey1677279793975 + implements MigrationInterface +{ + name = 'MenuItemCompositePrimaryKey1677279793975'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP FOREIGN KEY \`FK_8e20ca40202c116fdafe92cdc4e\``, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD \`parent_menu_id\` int NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` CHANGE \`id\` \`id\` int NOT NULL`, + ); + await queryRunner.query(`ALTER TABLE \`menu_items\` DROP PRIMARY KEY`); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD PRIMARY KEY (\`id\`, \`menu_id\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD CONSTRAINT \`FK_410d05637be571e7fc8973ae364\` FOREIGN KEY (\`parent_id\`, \`parent_menu_id\`) REFERENCES \`menu_items\`(\`id\`,\`menu_id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP FOREIGN KEY \`FK_410d05637be571e7fc8973ae364\``, + ); + await queryRunner.query(`ALTER TABLE \`menu_items\` DROP PRIMARY KEY`); + await queryRunner.query(`ALTER TABLE \`menu_items\` DROP COLUMN \`id\``); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD \`id\` int NOT NULL AUTO_INCREMENT, ADD PRIMARY KEY (\`id\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP COLUMN \`parent_menu_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD CONSTRAINT \`FK_8e20ca40202c116fdafe92cdc4e\` FOREIGN KEY (\`parent_id\`) REFERENCES \`menu_items\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } +} diff --git a/migrations/1677304809626-AddVersionAndTimestampsToMenu.ts b/migrations/1677304809626-AddVersionAndTimestampsToMenu.ts new file mode 100644 index 0000000..40e4b35 --- /dev/null +++ b/migrations/1677304809626-AddVersionAndTimestampsToMenu.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVersionAndTimestampsToMenu1677304809626 + implements MigrationInterface +{ + name = 'AddVersionAndTimestampsToMenu1677304809626'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`deleted_at\` datetime(6) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`version\` int NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`menus\` DROP COLUMN \`version\``); + await queryRunner.query(`ALTER TABLE \`menus\` DROP COLUMN \`deleted_at\``); + await queryRunner.query(`ALTER TABLE \`menus\` DROP COLUMN \`updated_at\``); + await queryRunner.query(`ALTER TABLE \`menus\` DROP COLUMN \`created_at\``); + } +} diff --git a/migrations/1677305371145-AddVersionAndTimestampsToMenuItem.ts b/migrations/1677305371145-AddVersionAndTimestampsToMenuItem.ts new file mode 100644 index 0000000..c758159 --- /dev/null +++ b/migrations/1677305371145-AddVersionAndTimestampsToMenuItem.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVersionAndTimestampsToMenuItem1677305371145 + implements MigrationInterface +{ + name = 'AddVersionAndTimestampsToMenuItem1677305371145'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD \`deleted_at\` datetime(6) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` ADD \`version\` int NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP COLUMN \`version\``, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP COLUMN \`deleted_at\``, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP COLUMN \`updated_at\``, + ); + await queryRunner.query( + `ALTER TABLE \`menu_items\` DROP COLUMN \`created_at\``, + ); + } +} diff --git a/migrations/1677320214393-AddPublishedRevisionToMenu.ts b/migrations/1677320214393-AddPublishedRevisionToMenu.ts new file mode 100644 index 0000000..c991397 --- /dev/null +++ b/migrations/1677320214393-AddPublishedRevisionToMenu.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPublishedRevisionToMenu1677320214393 + implements MigrationInterface +{ + name = 'AddPublishedRevisionToMenu1677320214393'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`published_revision_id\` int NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD \`published_revision_menu_id\` int NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` ADD CONSTRAINT \`FK_e4bf608a85adf722bc3015bbb8a\` FOREIGN KEY (\`published_revision_id\`, \`published_revision_menu_id\`) REFERENCES \`menu_revisions\`(\`id\`,\`menu_id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`menus\` DROP FOREIGN KEY \`FK_e4bf608a85adf722bc3015bbb8a\``, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` DROP COLUMN \`published_revision_menu_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`menus\` DROP COLUMN \`published_revision_id\``, + ); + } +} diff --git a/src/common/errors/entity-not-found.error.ts b/src/common/errors/entity-not-found.error.ts new file mode 100644 index 0000000..a20d46b --- /dev/null +++ b/src/common/errors/entity-not-found.error.ts @@ -0,0 +1,22 @@ +import { GraphQLError } from 'graphql'; + +export class EntityNotFoundError< + T extends string | { name: string }, +> extends GraphQLError { + constructor(entity: T, id: number) { + const e = + typeof entity === 'string' + ? entity + : entity.constructor?.name !== 'Function' + ? entity.constructor.name + : entity.name; + super(`${e} with id ${id} not found`, { + extensions: { + code: 'ENTITY_NOT_FOUND', + entity: e, + id, + }, + }); + this.name = 'EntityNotFoundError'; + } +} diff --git a/src/common/errors/field-validation.error.ts b/src/common/errors/field-validation.error.ts index d9a86a7..2ec774f 100644 --- a/src/common/errors/field-validation.error.ts +++ b/src/common/errors/field-validation.error.ts @@ -57,6 +57,7 @@ class FieldValidationError extends GraphQLError { IS_UNIQUE: 'isUnique', // Menu Meta META_TYPE_CANNOT_BE_CHANGED: 'metaTypeCannotBeChanged', + META_DEFAULT_VALUE_REQUIRED: 'metaDefaultValueRequired', // Menu Item Meta META_REQUIRED: 'metaRequired', }; diff --git a/src/common/helpers/paginate.helper.ts b/src/common/helpers/paginate.helper.ts index 15de914..2f13a0f 100644 --- a/src/common/helpers/paginate.helper.ts +++ b/src/common/helpers/paginate.helper.ts @@ -3,9 +3,10 @@ import { PageInfo } from '../schema/objects/page-info.object'; import { PaginationArgs } from '../schema/args/pagination.arg'; import { SelectQueryBuilder, MoreThan, LessThan } from 'typeorm'; import { Direction } from '../schema/enums/direction.enum'; +import { Connection } from '../types'; /** - * Based on https://gist.github.com/VojtaSim/6b03466f1964a6c81a3dbf1f8cec8d5c + * Based on https://gist.github.com/tumainimosha/6652deb0aea172f7f2c4b2077c72d16c */ export async function paginate( query: SelectQueryBuilder, @@ -13,7 +14,7 @@ export async function paginate( cursorColumn = 'id', direction = Direction.ASC, defaultLimit = 25, -): Promise { +): Promise> { const logger = new Logger('Pagination'); // pagination ordering @@ -27,7 +28,7 @@ export async function paginate( const offsetId = Number( Buffer.from(paginationArgs.after, 'base64').toString('ascii'), ); - logger.verbose(`Paginate AfterID: ${offsetId}`); + logger.verbose(`Paginate After ID: ${offsetId}`); query.where({ [cursorColumn]: MoreThan(offsetId) }); } @@ -41,7 +42,7 @@ export async function paginate( const offsetId = Number( Buffer.from(paginationArgs.before, 'base64').toString('ascii'), ); - logger.verbose(`Paginate BeforeID: ${offsetId}`); + logger.verbose(`Paginate Before ID: ${offsetId}`); const limit = paginationArgs.last ?? defaultLimit; @@ -81,8 +82,8 @@ export async function paginate( .getCount(); } - logger.debug(`CountBefore:${countBefore}`); - logger.debug(`CountAfter:${countAfter}`); + logger.verbose(`CountBefore: ${countBefore}`); + logger.verbose(`CountAfter: ${countAfter}`); const edges = result.map((value) => { return { diff --git a/src/common/schema/objects/versioned-timestamped.object.ts b/src/common/schema/objects/versioned-timestamped.object.ts new file mode 100644 index 0000000..0d7a4e4 --- /dev/null +++ b/src/common/schema/objects/versioned-timestamped.object.ts @@ -0,0 +1,28 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; +import { + CreateDateColumn, + DeleteDateColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +@ObjectType({ isAbstract: true }) +export abstract class Timestamped { + @Field(() => Date) + @CreateDateColumn() + createdAt: Date; + + @Field(() => Date) + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} + +@ObjectType({ isAbstract: true }) +export abstract class VersionedTimestamped extends Timestamped { + @Field(() => Int) + @VersionColumn() + version: number; +} diff --git a/src/common/types/model.type.ts b/src/common/types/model.type.ts index 32eff2a..0dedad7 100644 --- a/src/common/types/model.type.ts +++ b/src/common/types/model.type.ts @@ -1,4 +1,7 @@ +import { MenuItem } from 'src/menu-items/entities/menu-item.entity'; +import { MenuMeta } from 'src/menus/objects/menu-meta.object'; import { InputAction } from '../schema/enums/input-action.enum'; +import { PageInfo } from '../schema/objects/page-info.object'; export enum MenuMetaType { TEXT = 'text', @@ -26,4 +29,23 @@ export interface IMenuItemMeta { [index: number]: unknown; } +export interface MenuRevisionSnapshot { + name: string; + meta?: MenuMeta[]; + items?: MenuItem[]; + template?: string; + templateFormat?: string; +} + export type WithAction = T & { action?: InputAction }; + +export interface Edge { + cursor: string; + node: T; +} + +export interface Connection { + edges: Edge[]; + pageInfo: PageInfo; + totalCount: number; +} diff --git a/src/menu-items/entities/menu-item.entity.ts b/src/menu-items/entities/menu-item.entity.ts index 73f533e..745546d 100644 --- a/src/menu-items/entities/menu-item.entity.ts +++ b/src/menu-items/entities/menu-item.entity.ts @@ -1,5 +1,6 @@ import { Field, Int, ObjectType } from '@nestjs/graphql'; import { TemplateFormat } from 'src/common/enums/template-format.enum'; +import { VersionedTimestamped } from 'src/common/schema/objects/versioned-timestamped.object'; import { GraphQLJSONObject } from 'src/common/schema/scalars/json.scalar'; import { IMenuItemMeta } from 'src/common/types'; import { Menu } from 'src/menus/entities/menu.entity'; @@ -9,14 +10,14 @@ import { JoinColumn, ManyToOne, OneToMany, - PrimaryGeneratedColumn, + PrimaryColumn, } from 'typeorm'; @ObjectType() @Entity('menu_items') -export class MenuItem { +export class MenuItem extends VersionedTimestamped { @Field(() => Int) - @PrimaryGeneratedColumn() + @PrimaryColumn() id: number; @Field() @@ -63,7 +64,7 @@ export class MenuItem { menu: Menu; @Field(() => Int) - @Column() + @PrimaryColumn() menuId?: number; @Field(() => Boolean, { nullable: false }) diff --git a/src/menu-items/entities/subscribers/menu-item.subscriber.ts b/src/menu-items/entities/subscribers/menu-item.subscriber.ts index a39a724..530a3cb 100644 --- a/src/menu-items/entities/subscribers/menu-item.subscriber.ts +++ b/src/menu-items/entities/subscribers/menu-item.subscriber.ts @@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'; import FieldValidationError from 'src/common/errors/field-validation.error'; import { InputAction } from 'src/common/schema/enums/input-action.enum'; import { WithAction, MenuMetaType } from 'src/common/types'; +import { Menu } from 'src/menus/entities/menu.entity'; import { EntitySubscriberInterface, EventSubscriber, @@ -19,7 +20,12 @@ export class MenuItemSubscriber implements EntitySubscriberInterface { } async beforeInsert(event: InsertEvent) { - const { index, isChildren, childrenIndex, siblings } = event.entity as any; + const { index, isChildren, childrenIndex, siblings, menuId } = + event.entity as any; + if (!event.entity.menu) { + const menu = await event.manager.findOne(Menu, { where: { id: menuId } }); + event.entity.menu = menu; + } await this.validateMenuItem( event.entity, siblings, @@ -27,8 +33,9 @@ export class MenuItemSubscriber implements EntitySubscriberInterface { isChildren, childrenIndex, ); - event.entity = await this.setMetaIds(event.entity); + event.entity = await this.setMeta(event.entity); await this.validateMeta(event.entity, index, isChildren, childrenIndex); + await this.setId(event.entity); } async afterInsert(event: InsertEvent): Promise { @@ -43,9 +50,10 @@ export class MenuItemSubscriber implements EntitySubscriberInterface { } async beforeUpdate(event: UpdateEvent) { - const { index, isChildren, childrenIndex, siblings } = event.entity; + const { index, isChildren, childrenIndex, siblings, menuId } = event.entity; + const menu = await event.manager.findOne(Menu, { where: { id: menuId } }); const { databaseEntity } = event; - let menuItem = { ...databaseEntity, ...event.entity }; + let menuItem = { ...databaseEntity, ...event.entity, menu }; await this.validateMenuItem( menuItem, siblings, @@ -53,11 +61,33 @@ export class MenuItemSubscriber implements EntitySubscriberInterface { isChildren, childrenIndex, ); - menuItem = await this.setMetaIds(menuItem); + menuItem = await this.setMeta(menuItem); event.entity.meta = menuItem.meta; await this.validateMeta(menuItem, index, isChildren, childrenIndex); } + private async setId(menuItem: MenuItem) { + if (menuItem.id) return; + const menu = await menuItem.menu; + const allItems = []; + const items = await menu.items; + await Promise.all( + items.map(async (i) => { + const children = await i.children; + allItems.push(...[i, ...(children || [])]); + }), + ); + let lastId = allItems.reduce((acc, i) => (i.id > acc ? i.id : acc), 0); + menuItem.id = ++lastId; + const setChildrenId = (children: MenuItem[]) => { + children.forEach((c) => { + c.id = ++lastId; + if (c.children) setChildrenId(c.children); + }); + }; + if (menuItem.children) setChildrenId(menuItem.children); + } + private async validateMenuItem( menuItem: MenuItem, siblings: WithAction[], @@ -67,11 +97,12 @@ export class MenuItemSubscriber implements EntitySubscriberInterface { ) { const menu = await menuItem.menu; const items = await menu.items; - const allSiblings = items.filter( - (i) => - i.parentId === menuItem.parentId && - !siblings.find((s) => s.id === i.id), - ); + const allSiblings = + items?.filter( + (i) => + i.parentId === menuItem.parentId && + !siblings.find((s) => s.id === i.id), + ) || []; siblings = [...siblings, ...allSiblings].filter( (s: WithAction) => s.action !== InputAction.DELETE, ); @@ -114,13 +145,14 @@ export class MenuItemSubscriber implements EntitySubscriberInterface { } } - private async setMetaIds(menuItem: MenuItem): Promise { + private async setMeta(menuItem: MenuItem): Promise { const menu = await menuItem.menu; - if (!menu.meta?.length) return; + if (!menu.meta?.length) return menuItem; const meta = {}; menu.meta.forEach((m) => { if (menuItem.meta?.[m.id]) meta[m.id] = menuItem.meta[m.id]; - if (menuItem.meta?.[m.name]) meta[m.id] = menuItem.meta[m.name]; + else if (menuItem.meta?.[m.name]) meta[m.id] = menuItem.meta[m.name]; + else if (m.defaultValue) meta[m.id] = m.defaultValue; }); menuItem.meta = { ...meta }; return menuItem; diff --git a/src/menu-items/menu-items.service.ts b/src/menu-items/menu-items.service.ts index 5c8e609..32fd96b 100644 --- a/src/menu-items/menu-items.service.ts +++ b/src/menu-items/menu-items.service.ts @@ -4,10 +4,10 @@ import { Menu } from 'src/menus/entities/menu.entity'; import { EntityManager, Repository } from 'typeorm'; import { CreateMenuItemInput } from './inputs/create-menu-item.input'; import { MenuItem } from './entities/menu-item.entity'; -import { plainToClass } from 'class-transformer'; import { UpdateMenuItemInput } from './inputs/update-menu-item.input'; import { DeleteMenuItemInput } from './inputs/delete-menu-item.input'; import { InputAction } from 'src/common/schema/enums/input-action.enum'; +import { EntityNotFoundError } from 'src/common/errors/entity-not-found.error'; @Injectable() export class MenuItemsService { @@ -31,7 +31,6 @@ export class MenuItemsService { const saved = await manager.save(MenuItem, { ...item, - menu, menuId: menu.id, index, isChildren, @@ -79,9 +78,15 @@ export class MenuItemsService { const { children } = input; delete input.action; delete input.children; + try { + await manager + .getRepository(MenuItem) + .findOneOrFail({ where: { id: input.id, menuId: menu.id } }); + } catch (error) { + throw new EntityNotFoundError(MenuItem, input.id); + } const item = await manager.save(MenuItem, { ...input, - menu, menuId: menu.id, index, isChildren, @@ -116,7 +121,7 @@ export class MenuItemsService { children.filter((c, index2) => i !== index2), ); case InputAction.DELETE: - return this.remove(child as DeleteMenuItemInput, manager); + return this.remove(menu, child as DeleteMenuItemInput, manager); default: throw new Error('unexpected action'); } @@ -125,8 +130,16 @@ export class MenuItemsService { } } - async remove(input: DeleteMenuItemInput, manager: EntityManager) { - await manager.remove(plainToClass(MenuItem, { ...input })); + async remove(menu: Menu, input: DeleteMenuItemInput, manager: EntityManager) { + try { + const item = await manager + .getRepository(MenuItem) + .findOneOrFail({ where: { id: input.id, menuId: menu.id } }); + await manager.remove(item); + return true; + } catch (err) { + throw new EntityNotFoundError(MenuItem, input.id); + } } handle( @@ -158,7 +171,7 @@ export class MenuItemsService { siblings as UpdateMenuItemInput[], ); case InputAction.DELETE: - return this.remove(input as DeleteMenuItemInput, manager); + return this.remove(menu, input as DeleteMenuItemInput, manager); default: throw new Error('unexpected action'); } diff --git a/src/menus/entities/menu-revision.entity.ts b/src/menus/entities/menu-revision.entity.ts new file mode 100644 index 0000000..10a59e5 --- /dev/null +++ b/src/menus/entities/menu-revision.entity.ts @@ -0,0 +1,43 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; +import { GraphQLJSONObject } from 'src/common/schema/scalars/json.scalar'; +import { MenuRevisionSnapshot } from 'src/common/types'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; +import { Menu } from './menu.entity'; + +@ObjectType() +@Entity('menu_revisions') +export class MenuRevision { + @Field(() => Int) + @PrimaryColumn() + id: number; + + @Field(() => Int) + @PrimaryColumn() + menuId: number; + + @Field() + @Column('text') + description: string; + + @Field(() => GraphQLJSONObject) + @Column('text', { transformer: { from: JSON.parse, to: JSON.stringify } }) + snapshot: MenuRevisionSnapshot; + + @Field(() => Menu) + @ManyToOne(() => Menu, (menu) => menu.revisions, { + lazy: true, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }) + menu: Menu; + + @Field(() => Date) + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/menus/entities/menu.entity.ts b/src/menus/entities/menu.entity.ts index 0457b65..00928cb 100644 --- a/src/menus/entities/menu.entity.ts +++ b/src/menus/entities/menu.entity.ts @@ -3,11 +3,20 @@ import { TemplateFormat } from 'src/common/enums/template-format.enum'; import { Connection } from 'src/common/schema/objects/connection.object'; import { MenuMeta } from 'src/menus/objects/menu-meta.object'; import { MenuItem } from 'src/menu-items/entities/menu-item.entity'; -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { MenuRevision } from './menu-revision.entity'; +import { VersionedTimestamped } from 'src/common/schema/objects/versioned-timestamped.object'; @ObjectType() @Entity('menus') -export class Menu { +export class Menu extends VersionedTimestamped { @Field(() => Int) @PrimaryGeneratedColumn() id: number; @@ -37,6 +46,43 @@ export class Menu { cascade: true, }) items?: MenuItem[]; + + @Field(() => [MenuRevision], { nullable: true }) + @OneToMany(() => MenuRevision, (revision) => revision.menu, { + lazy: true, + cascade: true, + }) + revisions?: MenuRevision[]; + + @Field(() => MenuRevision, { nullable: true }) + @ManyToOne(() => MenuRevision, { + nullable: true, + eager: true, + cascade: true, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }) + @JoinColumn() + currentRevision?: MenuRevision; + + @Field(() => Int, { nullable: true }) + @Column({ nullable: true }) + currentRevisionId?: number; + + @Field(() => MenuRevision, { nullable: true }) + @ManyToOne(() => MenuRevision, { + nullable: true, + eager: true, + cascade: true, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }) + @JoinColumn() + publishedRevision?: MenuRevision; + + @Field(() => Int, { nullable: true }) + @Column({ nullable: true }) + publishedRevisionId?: number; } @ObjectType() diff --git a/src/menus/entities/subscribers/menu.subscriber.ts b/src/menus/entities/subscribers/menu.subscriber.ts index e2f98cc..28b3e37 100644 --- a/src/menus/entities/subscribers/menu.subscriber.ts +++ b/src/menus/entities/subscribers/menu.subscriber.ts @@ -23,8 +23,12 @@ export class MenuSubscriber implements EntitySubscriberInterface { } } - beforeUpdate(event: UpdateEvent): void { + async beforeUpdate(event: UpdateEvent): Promise { if (event.entity.meta) { + if (event.queryRunner.data.replaceMeta) { + this.validateMeta(event.entity.meta); + return; + } this.validateMeta(event.entity.meta, event.databaseEntity.meta); const updatedMeta = event.databaseEntity.meta .map((dbMeta) => { @@ -33,6 +37,9 @@ export class MenuSubscriber implements EntitySubscriberInterface { return { ...dbMeta, ...meta }; }) .filter((m) => m.action !== InputAction.DELETE); + const deletedMeta = event.entity.meta.filter( + (m) => m.action === InputAction.DELETE, + ); const newMeta = event.entity.meta .filter((m) => m.action === InputAction.CREATE) .map((m) => { @@ -40,6 +47,28 @@ export class MenuSubscriber implements EntitySubscriberInterface { return m; }); event.entity.meta = [...updatedMeta, ...newMeta]; + const items = await event.databaseEntity.items; + if (items?.length) { + Promise.all( + items.map(async (item: any, index) => { + if (item.meta) { + item.meta = Object.keys(item.meta) + .filter( + (metaId) => !deletedMeta.find((m) => m.id === Number(metaId)), + ) + .reduce((acc, metaId) => { + acc[metaId] = item.meta[metaId]; + return acc; + }, {}); + } + item.index = index; + item.siblings = items.filter( + (i, index2) => index !== index2 && i.parentId === item.parentId, + ); + await event.manager.save(MenuItem, item); + }), + ); + } } } @@ -86,8 +115,11 @@ export class MenuSubscriber implements EntitySubscriberInterface { ): void { const metaWithIndex = meta.map((m, index) => ({ ...m, index })); const errors = {}; - const { IS_UNIQUE, META_TYPE_CANNOT_BE_CHANGED } = - FieldValidationError.constraints; + const { + IS_UNIQUE, + META_TYPE_CANNOT_BE_CHANGED, + META_DEFAULT_VALUE_REQUIRED, + } = FieldValidationError.constraints; // Check if ids are unique metaWithIndex .filter((m) => { @@ -234,6 +266,23 @@ export class MenuSubscriber implements EntitySubscriberInterface { }, }; }); + // Check if default value is set when required + metaWithIndex + .filter((m) => { + const dbMetaItem = dbMeta.find((m2) => m2.id === m.id); + const required = + m.required !== undefined ? m.required : dbMetaItem.required; + return dbMetaItem && required && !m.defaultValue; + }) + .forEach((m) => { + errors[`meta[${m.index}]`] = { + ...errors[`meta[${m.index}]`], + defaultValue: { + errors: [`Menu meta default value must be set when required.`], + constraints: [META_DEFAULT_VALUE_REQUIRED], + }, + }; + }); } if (Object.keys(errors).length) throw new FieldValidationError(errors); } diff --git a/src/menus/inputs/create-menu-meta.input.ts b/src/menus/inputs/create-menu-meta.input.ts index 4054252..1577af6 100644 --- a/src/menus/inputs/create-menu-meta.input.ts +++ b/src/menus/inputs/create-menu-meta.input.ts @@ -4,9 +4,9 @@ import { IsDefined, IsEnum, IsNotEmpty, - IsOptional, MaxLength, MinLength, + ValidateIf, } from 'class-validator'; import { InputAction } from 'src/common/schema/enums/input-action.enum'; import GraphQLJSON from 'src/common/schema/scalars/json.scalar'; @@ -41,6 +41,8 @@ export class CreateMenuMetaInput { enabled: boolean; @Field(() => GraphQLJSON, { nullable: true }) - @IsOptional() + @ValidateIf((o) => o.required) + @IsDefined() + @IsNotEmpty() defaultValue?: any; } diff --git a/src/menus/inputs/create-menu-revision.input.ts b/src/menus/inputs/create-menu-revision.input.ts new file mode 100644 index 0000000..da3db53 --- /dev/null +++ b/src/menus/inputs/create-menu-revision.input.ts @@ -0,0 +1,26 @@ +import { Field, InputType, Int } from '@nestjs/graphql'; +import { + IsBoolean, + IsDefined, + IsNotEmpty, + MaxLength, + MinLength, +} from 'class-validator'; + +@InputType() +export class CreateMenuRevisionInput { + @Field(() => Int) + @IsDefined() + menuId: number; + + @Field() + @IsNotEmpty() + @MinLength(3) + @MaxLength(255) + description: string; + + @Field(() => Boolean) + @IsDefined() + @IsBoolean() + setAsCurrent: boolean; +} diff --git a/src/menus/inputs/update-menu-meta.input.ts b/src/menus/inputs/update-menu-meta.input.ts index 41ef9e3..c80cc99 100644 --- a/src/menus/inputs/update-menu-meta.input.ts +++ b/src/menus/inputs/update-menu-meta.input.ts @@ -56,7 +56,8 @@ export class UpdateMenuMetaInput { enabled?: boolean; @Field(() => GraphQLJSON, { nullable: true }) - @ValidateIf((o) => o.action === InputAction.UPDATE && o.required) + @ValidateIf((o) => o.required) @IsDefined() + @IsNotEmpty() defaultValue?: any; } diff --git a/src/menus/menus.module.ts b/src/menus/menus.module.ts index 2aa95e5..ad05d49 100644 --- a/src/menus/menus.module.ts +++ b/src/menus/menus.module.ts @@ -4,9 +4,14 @@ import { MenusResolver } from './menus.resolver'; import { MenuItemsModule } from 'src/menu-items/menu-items.module'; import { Menu } from './entities/menu.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { MenuRevision } from './entities/menu-revision.entity'; +import { MenuItem } from 'src/menu-items/entities/menu-item.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Menu]), MenuItemsModule], + imports: [ + TypeOrmModule.forFeature([Menu, MenuItem, MenuRevision]), + MenuItemsModule, + ], providers: [MenusResolver, MenusService], }) export class MenusModule {} diff --git a/src/menus/menus.resolver.ts b/src/menus/menus.resolver.ts index f2d3b0e..e360850 100644 --- a/src/menus/menus.resolver.ts +++ b/src/menus/menus.resolver.ts @@ -5,6 +5,7 @@ import { CreateMenuInput } from './inputs/create-menu.input'; import { UpdateMenuInput } from './inputs/update-menu.input'; import { PaginationArgs } from 'src/common/schema/args/pagination.arg'; import { FindMenuSortArgs } from './args/find-menu-sort.arg'; +import { CreateMenuRevisionInput } from './inputs/create-menu-revision.input'; @Resolver(() => Menu) export class MenusResolver { @@ -31,17 +32,31 @@ export class MenusResolver { } @Mutation(() => Boolean) - async removeMenu(@Args('id', { type: () => Int }) id: number) { - //return this.menusService.remove(id); - await this.menusService.remove(id); + removeMenu(@Args('id', { type: () => Int }) id: number) { + return this.menusService.remove(id); + } - return true; + @Mutation(() => Menu) + createRevision( + @Args('createMenuRevisionInput') + createMenuRevisionInput: CreateMenuRevisionInput, + ) { + return this.menusService.createRevision(createMenuRevisionInput); } - // @ResolveField('items', () => [MenuItem]) - // getItems(@Parent() menu: Menu) { - // //const { id } = menu; - // //return this.menuItemsService.findAll({ menuId: id }); - // return menu.items; - // } + @Mutation(() => Menu) + restoreRevision( + @Args('menuId', { type: () => Int }) menuId: number, + @Args('revisionId', { type: () => Int }) revisionId: number, + ) { + return this.menusService.restoreRevision(menuId, revisionId); + } + + @Mutation(() => Menu) + publishRevision( + @Args('menuId', { type: () => Int }) menuId: number, + @Args('revisionId', { type: () => Int }) revisionId: number, + ) { + return this.menusService.publishRevision(menuId, revisionId); + } } diff --git a/src/menus/menus.service.ts b/src/menus/menus.service.ts index 8d507ac..b3f8543 100644 --- a/src/menus/menus.service.ts +++ b/src/menus/menus.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { MenuItemsService } from 'src/menu-items/menu-items.service'; -import { DataSource, Repository } from 'typeorm'; +import { + DataSource, + EntityNotFoundError as EntityNotFoundErrorTypeOrm, + Repository, +} from 'typeorm'; import { CreateMenuInput } from './inputs/create-menu.input'; import { UpdateMenuInput } from './inputs/update-menu.input'; import { Menu } from './entities/menu.entity'; @@ -11,6 +15,10 @@ import { paginate } from 'src/common/helpers/paginate.helper'; import { UpdateMenuMetaInput } from './inputs/update-menu-meta.input'; import { InputAction } from 'src/common/schema/enums/input-action.enum'; import { MenuMeta } from './objects/menu-meta.object'; +import { MenuRevision } from './entities/menu-revision.entity'; +import { CreateMenuRevisionInput } from './inputs/create-menu-revision.input'; +import { MenuItem } from 'src/menu-items/entities/menu-item.entity'; +import { EntityNotFoundError } from 'src/common/errors/entity-not-found.error'; @Injectable() export class MenusService { @@ -18,6 +26,10 @@ export class MenusService { private dataSource: DataSource, @InjectRepository(Menu) private menuRepository: Repository, + @InjectRepository(MenuItem) + private itemRepository: Repository, + @InjectRepository(MenuRevision) + private revisionRepository: Repository, private readonly menuItemsService: MenuItemsService, ) {} @@ -48,7 +60,11 @@ export class MenusService { } findOne(id: number) { - return this.menuRepository.findOneBy({ id: id }); + try { + return this.menuRepository.findOneByOrFail({ id: id }); + } catch (err) { + throw new EntityNotFoundError(Menu, id); + } } async update(id: number, updateMenuInput: UpdateMenuInput) { @@ -58,25 +74,26 @@ export class MenusService { await queryRunner.startTransaction(); try { - const { meta, ...rest } = updateMenuInput; + const { meta, items, ...rest } = updateMenuInput; const menu = await queryRunner.manager .getRepository(Menu) - .preload({ id, ...rest }); + .findOneOrFail({ where: { id } }); + Object.assign(menu, rest); const updatedMeta = this.handleMeta(menu, meta); menu.meta = updatedMeta as MenuMeta[]; const saved = await queryRunner.manager.save(menu); - if (updateMenuInput.items) { + if (items) { await Promise.all( - updateMenuInput.items.map((mii, i) => + items.map((mii, i) => this.menuItemsService.handle( saved, mii, queryRunner.manager, i, - updateMenuInput.items.filter((m2, i2) => i2 !== i), + items.filter((m2, i2) => i2 !== i), ), ), ); @@ -90,14 +107,23 @@ export class MenusService { }); } catch (err) { await queryRunner.rollbackTransaction(); + if (err instanceof EntityNotFoundErrorTypeOrm) { + throw new EntityNotFoundError(Menu, id); + } throw err; } finally { await queryRunner.release(); } } - remove(id: number) { - return this.menuRepository.delete(id); + async remove(id: number) { + try { + const menu = await this.menuRepository.findOneOrFail({ where: { id } }); + await this.menuRepository.remove(menu); + return true; + } catch (err) { + throw new EntityNotFoundError(Menu, id); + } } handleMeta(menu: Menu, input?: UpdateMenuMetaInput[]) { @@ -118,4 +144,172 @@ export class MenusService { } return updatedMeta.sort((a, b) => a.order - b.order); } + + async createRevision({ + setAsCurrent, + menuId, + description, + }: CreateMenuRevisionInput) { + try { + const { name, meta, template, templateFormat } = + await this.menuRepository.findOneOrFail({ + where: { id: menuId }, + }); + + const items = await this.itemRepository.find({ + where: { menuId }, + }); + + const snapshot = { + name, + meta, + template, + templateFormat, + items, + }; + + const revisions = await this.revisionRepository.find({ + where: { menuId }, + }); + + let id = 1; + if (revisions?.length) { + id = revisions.sort((a, b) => b.id - a.id)[0].id + 1; + } + + const revision = await this.revisionRepository.create({ + description, + menuId, + snapshot, + id, + }); + + if (setAsCurrent) { + await this.menuRepository.save({ + id: menuId, + currentRevision: revision, + }); + } else { + await this.revisionRepository.save(revision); + } + + return this.menuRepository.findOne({ + where: { id: menuId }, + relations: ['items', 'revisions'], + }); + } catch (err) { + if (err instanceof EntityNotFoundErrorTypeOrm) { + throw new EntityNotFoundError(Menu, menuId); + } + throw err; + } + } + + async restoreRevision(menuId: number, revisionId: number) { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + let menu; + try { + menu = await queryRunner.manager + .getRepository(Menu) + .findOneOrFail({ where: { id: menuId } }); + } catch (err) { + throw new EntityNotFoundError(Menu, menuId); + } + + const revision = await queryRunner.manager + .getRepository(MenuRevision) + .findOneOrFail({ where: { menuId, id: revisionId } }); + + const previousItems = await queryRunner.manager + .getRepository(MenuItem) + .find({ where: { menuId } }); + + await queryRunner.manager.remove(previousItems); + + const { items, ...snapshot } = revision.snapshot; + + menu = await queryRunner.manager.save( + Menu, + { + ...menu, + ...snapshot, + currentRevision: revision, + }, + { data: { replaceMeta: true } }, + ); + + const itemsWithMenu = items.map((i, index) => { + const siblings = items.filter( + (m2, i2) => i2 !== index && m2.parentId === i.parentId, + ); + return { ...i, menu, index, siblings }; + }); + + const savedItems = await queryRunner.manager.save( + MenuItem, + itemsWithMenu, + ); + + await queryRunner.commitTransaction(); + + menu.items = savedItems; + + return this.menuRepository.findOne({ + where: { id: menu.id }, + relations: ['items', 'revisions'], + }); + } catch (err) { + await queryRunner.rollbackTransaction(); + if (err instanceof EntityNotFoundErrorTypeOrm) { + throw new EntityNotFoundError(MenuRevision, revisionId); + } + throw err; + } finally { + await queryRunner.release(); + } + } + + async publishRevision(menuId: number, revisionId: number) { + try { + let menu; + try { + menu = await this.menuRepository.findOneOrFail({ + where: { id: menuId }, + relations: ['items'], + }); + } catch (err) { + throw new EntityNotFoundError(Menu, menuId); + } + + if (menu.publishedRevision?.id === revisionId) { + return menu; + } + + const revision = await this.revisionRepository.findOneOrFail({ + where: { menuId, id: revisionId }, + }); + + // TODO: publish on service + + menu = await this.menuRepository.save({ + ...menu, + publishedRevision: revision, + }); + + return this.menuRepository.findOne({ + where: { id: menu.id }, + relations: ['items', 'revisions'], + }); + } catch (err) { + if (err instanceof EntityNotFoundErrorTypeOrm) { + throw new EntityNotFoundError(MenuRevision, revisionId); + } + throw err; + } + } } diff --git a/src/schema.gql b/src/schema.gql index 81e1d88..1165d5d 100644 --- a/src/schema.gql +++ b/src/schema.gql @@ -24,13 +24,40 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// """ scalar JSON +type MenuRevision { + id: Int! + menuId: Int! + description: String! + snapshot: JSONObject! + menu: Menu! + createdAt: DateTime! +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + type Menu { + createdAt: DateTime! + updatedAt: DateTime! + version: Int! id: Int! name: String! meta: [MenuMeta!] template: String templateFormat: String items: [MenuItem!] + revisions: [MenuRevision!] + currentRevision: MenuRevision + currentRevisionId: Int + publishedRevision: MenuRevision + publishedRevisionId: Int } type MenuConnection { @@ -45,6 +72,9 @@ type MenuEdge { } type MenuItem { + createdAt: DateTime! + updatedAt: DateTime! + version: Int! id: Int! label: String! order: Int! @@ -60,16 +90,6 @@ type MenuItem { templateFormat: String } -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject - -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - type Query { menus(first: Int, after: String, last: Int, before: String, sort: MenuSort, direction: Direction): MenuConnection! menu(id: Int!): Menu! @@ -90,6 +110,9 @@ type Mutation { createMenu(createMenuInput: CreateMenuInput!): Menu! updateMenu(updateMenuInput: UpdateMenuInput!): Menu! removeMenu(id: Int!): Boolean! + createRevision(createMenuRevisionInput: CreateMenuRevisionInput!): Menu! + restoreRevision(menuId: Int!, revisionId: Int!): Menu! + publishRevision(menuId: Int!, revisionId: Int!): Menu! } input CreateMenuInput { @@ -157,4 +180,10 @@ input UpdateMenuMetaInput { order: Int enabled: Boolean defaultValue: JSON +} + +input CreateMenuRevisionInput { + menuId: Int! + description: String! + setAsCurrent: Boolean! } \ No newline at end of file