diff --git a/src/services/action/repositories/action.ts b/src/services/action/repositories/action.ts index 8d6746bde3..e3e720e453 100644 --- a/src/services/action/repositories/action.ts +++ b/src/services/action/repositories/action.ts @@ -10,8 +10,9 @@ import { aggregateExpressionNames, buildAggregateExpression } from '../utils/act */ export const ActionRepository = AppDataSource.getRepository(Action).extend({ /** - * Create given action and return it. - * @param action Action to create + * TODO: remove if it not used. + * Create given actions. + * @param action Actions to create */ async postMany(actions: Pick[]): Promise { // save action @@ -20,6 +21,15 @@ export const ActionRepository = AppDataSource.getRepository(Action).extend({ } }, + /** + * Create given action. + * @param action Action to create + */ + async post(action: Pick): Promise { + // save action + await this.insert(action); + }, + /** * Delete actions matching the given `memberId`. Return actions, or `null`, if delete has no effect. * @param memberId ID of the member whose actions are deleted diff --git a/src/services/action/services/action.ts b/src/services/action/services/action.ts index bb9b906d6c..3bc701fea4 100644 --- a/src/services/action/services/action.ts +++ b/src/services/action/services/action.ts @@ -23,6 +23,7 @@ export class ActionService { this.memberService = memberService; } + // TODO: remove if it not used async postMany( member: Actor, repositories: Repositories, @@ -59,4 +60,40 @@ export class ActionService { await repositories.actionRepository.postMany(completeActions); } + + async post( + member: Actor, + repositories: Repositories, + request: FastifyRequest, + action: Partial & Pick, + ): Promise { + const { headers } = request; + + // prevent saving if member disabled + const enableMemberSaving = member?.extra?.enableSaveActions ?? true; + if (!enableMemberSaving) { + return; + } + + // prevent saving if item disabled analytics + if (action.item?.settings?.enableSaveActions) { + return; + } + + const view = getView(headers); + // warning: addresses might contained spoofed ips + const addresses = forwarded(request.raw); + const ip = addresses.pop(); + + const geolocation = ip ? getGeolocationIp(ip) : null; + const completeAction = { + member, + geolocation: geolocation ?? undefined, + view, + extra: {}, + ...action, + }; + + await repositories.actionRepository.post(completeAction); + } } diff --git a/src/services/item/controller.ts b/src/services/item/controller.ts index 23e85ef7fa..e680c55d92 100644 --- a/src/services/item/controller.ts +++ b/src/services/item/controller.ts @@ -2,7 +2,7 @@ import { StatusCodes } from 'http-status-codes'; import { FastifyPluginAsync } from 'fastify'; -import { IdParam, IdsParams, ParentIdParam, PermissionLevel } from '@graasp/sdk'; +import { IdParam, IdsParams, ParentIdParam, PermissionLevel, getParentFromPath } from '@graasp/sdk'; import { PaginationParams } from '../../types'; import { UnauthorizedMember } from '../../utils/errors'; @@ -24,8 +24,9 @@ import { updateMany, } from './fluent-schema'; import { ItemGeolocation } from './plugins/geolocation/ItemGeolocation'; -import { ItemChildrenParams, ItemSearchParams } from './types'; -import { ItemOpFeedbackEvent, memberItemsTopic } from './ws/events'; +import { ItemChildrenParams, ItemSearchParams, PromiseRunner } from './types'; +import { ItemOpFeedbackEvent, ResultOfFactory, memberItemsTopic } from './ws/events'; +import { ItemWebsocketsService } from './ws/services'; const plugin: FastifyPluginAsync = async (fastify) => { const { db, items, websockets } = fastify; @@ -211,7 +212,7 @@ const plugin: FastifyPluginAsync = async (fastify) => { websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('update', ids, { error: e }), + ItemOpFeedbackEvent('update', ids, ResultOfFactory.withError(e)), ); } }); @@ -252,7 +253,7 @@ const plugin: FastifyPluginAsync = async (fastify) => { websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('delete', ids, { error: e }), + ItemOpFeedbackEvent('delete', ids, ResultOfFactory.withError(e)), ); } }); @@ -271,30 +272,46 @@ const plugin: FastifyPluginAsync = async (fastify) => { body: { parentId }, log, } = request; - db.transaction(async (manager) => { - const repositories = buildRepositories(manager); - const items = await itemService.moveMany(member, repositories, ids, parentId); - await actionItemService.postManyMoveAction(request, reply, repositories, items); - if (member) { - websockets.publish( - memberItemsTopic, - member.id, - ItemOpFeedbackEvent('move', ids, { - data: Object.fromEntries(items.map((i) => [i.id, i])), - errors: [], - }), - ); - } - }).catch((e) => { - log.error(e); + const itemWsService = new ItemWebsocketsService(websockets); + + PromiseRunner.inSeries( + ids.map((itemId) => { + return async () => { + return db + .transaction(async (manager) => { + const repositories = buildRepositories(manager); + const { source, destination } = await itemService.move( + member, + repositories, + itemId, + parentId, + ); + + await actionItemService.postMoveAction(request, repositories, destination); + + return { source, destination }; + }) + .then(({ source, destination }) => { + itemWsService.publishTopicsForMove({ + source, + destination, + sourceParentId: getParentFromPath(source.path), + }); + return destination; + }); + }; + }), + ).then((results) => { if (member) { - websockets.publish( - memberItemsTopic, - member.id, - ItemOpFeedbackEvent('move', ids, { error: e }), - ); + itemWsService.publishFeedbacksForMove({ + log, + results, + itemIds: ids, + memberId: member.id, + }); } }); + reply.status(StatusCodes.ACCEPTED); return ids; }, @@ -335,7 +352,7 @@ const plugin: FastifyPluginAsync = async (fastify) => { websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('copy', ids, { error: e }), + ItemOpFeedbackEvent('copy', ids, ResultOfFactory.withError(e)), ); } }); diff --git a/src/services/item/plugins/action/index.ts b/src/services/item/plugins/action/index.ts index 7c11ce3c68..5d7bccdaa8 100644 --- a/src/services/item/plugins/action/index.ts +++ b/src/services/item/plugins/action/index.ts @@ -20,7 +20,7 @@ import { LocalFileConfiguration, S3FileConfiguration, } from '../../../file/interfaces/configuration'; -import { ItemOpFeedbackEvent, memberItemsTopic } from '../../ws/events'; +import { ItemOpFeedbackEvent, ResultOfFactory, memberItemsTopic } from '../../ws/events'; import { CannotPostAction } from './errors'; import { ActionRequestExportService } from './requestExport/service'; import { exportAction, getAggregateActions, getItemActions, postAction } from './schemas'; @@ -175,7 +175,7 @@ const plugin: FastifyPluginAsync = async (fastify) => { websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('export', [itemId], { error: e }), + ItemOpFeedbackEvent('export', [itemId], ResultOfFactory.withError(e)), ); } }); diff --git a/src/services/item/plugins/action/service.ts b/src/services/item/plugins/action/service.ts index c6a5537b33..9781127beb 100644 --- a/src/services/item/plugins/action/service.ts +++ b/src/services/item/plugins/action/service.ts @@ -280,6 +280,7 @@ export class ActionItemService { await this.actionService.postMany(member, repositories, request, actions); } + // TODO: remove it if it is never used async postManyMoveAction( request: FastifyRequest, reply: FastifyReply, @@ -297,6 +298,18 @@ export class ActionItemService { await this.actionService.postMany(member, repositories, request, actions); } + async postMoveAction(request: FastifyRequest, repositories: Repositories, item: Item) { + const { member } = request; + const action = { + item, + type: ItemActionType.Move, + // TODO: remove any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra: { itemId: item.id, body: request.body as any }, + }; + await this.actionService.post(member, repositories, request, action); + } + async postManyCopyAction( request: FastifyRequest, reply: FastifyReply, diff --git a/src/services/item/plugins/action/test/ws.test.ts b/src/services/item/plugins/action/test/ws.test.ts index ed02a2d1dd..d185d7f545 100644 --- a/src/services/item/plugins/action/test/ws.test.ts +++ b/src/services/item/plugins/action/test/ws.test.ts @@ -7,7 +7,7 @@ import { clearDatabase } from '../../../../../../test/app'; import { saveItemAndMembership } from '../../../../itemMembership/test/fixtures/memberships'; import { TestWsClient } from '../../../../websockets/test/test-websocket-client'; import { setupWsApp } from '../../../../websockets/test/ws-app'; -import { ItemOpFeedbackEvent, memberItemsTopic } from '../../../ws/events'; +import { ItemOpFeedbackEvent, ResultOfFactory, memberItemsTopic } from '../../../ws/events'; import { ActionRequestExportRepository } from '../requestExport/repository'; // mock datasource @@ -97,7 +97,11 @@ describe('asynchronous feedback', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('export', [item.id], { error: new Error('mock error') }), + ItemOpFeedbackEvent( + 'export', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); diff --git a/src/services/item/plugins/recycled/index.ts b/src/services/item/plugins/recycled/index.ts index 7a1aca6a33..c98dc05ede 100644 --- a/src/services/item/plugins/recycled/index.ts +++ b/src/services/item/plugins/recycled/index.ts @@ -5,7 +5,7 @@ import { FastifyPluginAsync } from 'fastify'; import { IdParam, IdsParams, MAX_TARGETS_FOR_READ_REQUEST } from '@graasp/sdk'; import { buildRepositories } from '../../../../utils/repositories'; -import { ItemOpFeedbackEvent, memberItemsTopic } from '../../ws/events'; +import { ItemOpFeedbackEvent, ResultOfFactory, memberItemsTopic } from '../../ws/events'; import schemas, { getRecycledItemDatas, recycleMany, restoreMany } from './schemas'; import { RecycledBinService } from './service'; import { recycleWsHooks } from './ws/hooks'; @@ -88,7 +88,7 @@ const plugin: FastifyPluginAsync = async (fastify, opti websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('recycle', ids, { error: e }), + ItemOpFeedbackEvent('recycle', ids, ResultOfFactory.withError(e)), ); } }); @@ -138,7 +138,7 @@ const plugin: FastifyPluginAsync = async (fastify, opti websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('restore', ids, { error: e }), + ItemOpFeedbackEvent('restore', ids, ResultOfFactory.withError(e)), ); } }); diff --git a/src/services/item/plugins/recycled/test/ws.test.ts b/src/services/item/plugins/recycled/test/ws.test.ts index b09222e8e5..14d65e6dd7 100644 --- a/src/services/item/plugins/recycled/test/ws.test.ts +++ b/src/services/item/plugins/recycled/test/ws.test.ts @@ -18,6 +18,7 @@ import { ItemEvent, ItemOpFeedbackEvent, OwnItemsEvent, + ResultOfFactory, SelfItemEvent, SharedItemsEvent, itemTopic, @@ -710,9 +711,11 @@ describe('Recycle websocket hooks', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('recycle', [item.id], { - error: new Error('mock error'), - }), + ItemOpFeedbackEvent( + 'recycle', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); @@ -771,9 +774,11 @@ describe('Recycle websocket hooks', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('restore', [item.id], { - error: new Error('mock error'), - }), + ItemOpFeedbackEvent( + 'restore', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); diff --git a/src/services/item/plugins/validation/index.ts b/src/services/item/plugins/validation/index.ts index d117a72bf2..43701c8694 100644 --- a/src/services/item/plugins/validation/index.ts +++ b/src/services/item/plugins/validation/index.ts @@ -4,7 +4,7 @@ import { FastifyPluginAsync } from 'fastify'; import { UnauthorizedMember } from '../../../../utils/errors'; import { buildRepositories } from '../../../../utils/repositories'; -import { ItemOpFeedbackEvent, memberItemsTopic } from '../../ws/events'; +import { ItemOpFeedbackEvent, ResultOfFactory, memberItemsTopic } from '../../ws/events'; import { itemValidation, itemValidationGroup } from './schemas'; import { ItemValidationService } from './service'; @@ -98,7 +98,7 @@ const plugin: FastifyPluginAsync = async (fastify websockets.publish( memberItemsTopic, member.id, - ItemOpFeedbackEvent('validate', [itemId], { error: e }), + ItemOpFeedbackEvent('validate', [itemId], ResultOfFactory.withError(e)), ); } }); diff --git a/src/services/item/plugins/validation/test/ws.test.ts b/src/services/item/plugins/validation/test/ws.test.ts index 139639e139..6a1fb235c8 100644 --- a/src/services/item/plugins/validation/test/ws.test.ts +++ b/src/services/item/plugins/validation/test/ws.test.ts @@ -8,7 +8,12 @@ import { ITEMS_ROUTE_PREFIX } from '../../../../../utils/config'; import { saveItemAndMembership } from '../../../../itemMembership/test/fixtures/memberships'; import { TestWsClient } from '../../../../websockets/test/test-websocket-client'; import { setupWsApp } from '../../../../websockets/test/ws-app'; -import { ItemEvent, ItemOpFeedbackEvent, memberItemsTopic } from '../../../ws/events'; +import { + ItemEvent, + ItemOpFeedbackEvent, + ResultOfFactory, + memberItemsTopic, +} from '../../../ws/events'; import { ItemValidationGroupRepository } from '../repositories/ItemValidationGroup'; import { saveItemValidation } from './utils'; @@ -82,9 +87,11 @@ describe('asynchronous feedback', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('validate', [item.id], { - error: new Error('mock error'), - }), + ItemOpFeedbackEvent( + 'validate', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); diff --git a/src/services/item/service.ts b/src/services/item/service.ts index f2b8a15398..40167af0de 100644 --- a/src/services/item/service.ts +++ b/src/services/item/service.ts @@ -370,6 +370,7 @@ export class ItemService { } const { itemRepository } = repositories; + // TODO: check memberships let parentItem; if (toItemId) { @@ -389,13 +390,6 @@ export class ItemService { await itemRepository.checkHierarchyDepth(parentItem, levelsToFarthestChild); } - // post hook - // question: invoque on all items? - await this.hooks.runPreHooks('move', actor, repositories, { - source: item, - destinationParent: parentItem, - }); - const result = await this._move(actor, repositories, item, parentItem); await this.hooks.runPostHooks('move', actor, repositories, { @@ -404,19 +398,7 @@ export class ItemService { destination: result, }); - return result; - } - - // TODO: optimize - async moveMany(actor: Actor, repositories: Repositories, itemIds: string[], toItemId?: string) { - if (!actor) { - throw new UnauthorizedMember(actor); - } - - const items = await Promise.all( - itemIds.map((id) => this.move(actor, repositories, id, toItemId)), - ); - return items; + return { destination: result, source: item }; } /** diff --git a/src/services/item/test/ws.test.ts b/src/services/item/test/ws.test.ts index de0e56b545..1894ee30cc 100644 --- a/src/services/item/test/ws.test.ts +++ b/src/services/item/test/ws.test.ts @@ -7,6 +7,7 @@ import { HttpMethod, PermissionLevel, Websocket, + getParentFromPath, parseStringToDate, } from '@graasp/sdk'; @@ -27,6 +28,7 @@ import { ItemEvent, ItemOpFeedbackEvent, OwnItemsEvent, + ResultOfFactory, SelfItemEvent, SharedItemsEvent, itemTopic, @@ -433,11 +435,11 @@ describe('Item websocket hooks', () => { }); expect(response.statusCode).toBe(StatusCodes.ACCEPTED); - await waitForExpect(async () => { - expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( - newParentItem.path, - ); - }); + // When the websocket is not received, indicates that the transaction + // is still in progress, the item should contain the old path. + expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( + oldParentItem.path, + ); await waitForExpect(() => { const [childDelete] = itemUpdates; @@ -445,6 +447,12 @@ describe('Item websocket hooks', () => { ChildItemEvent('delete', parseStringToDate(childItem) as Item), ); }); + + // When the websocket is received, indicates that the transaction + // is done, the item should contain the new path. + expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( + newParentItem.path, + ); }); it('parent of new location receives child creation update', async () => { @@ -464,20 +472,25 @@ describe('Item websocket hooks', () => { }); expect(response.statusCode).toBe(StatusCodes.ACCEPTED); - await waitForExpect(async () => { - expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( - newParentItem.path, - ); - }); - - const moved = await ItemRepository.findOneBy({ id: childItem.id }); + // When the websocket is not received, indicates that the transaction + // is still in progress, the item should contain the old path. + expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( + oldParentItem.path, + ); - await waitForExpect(() => { + await waitForExpect(async () => { const [childCreate] = itemUpdates; + const moved = await ItemRepository.findOneBy({ id: childItem.id }); expect(childCreate).toMatchObject( ChildItemEvent('create', parseStringToDate(moved) as Item), ); }); + + // When the websocket is received, indicates that the transaction + // is done, the item should contain the new path. + expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( + newParentItem.path, + ); }); it('creator receives own items delete update if old location was root of creator', async () => { @@ -495,11 +508,13 @@ describe('Item websocket hooks', () => { }); expect(response.statusCode).toBe(StatusCodes.ACCEPTED); - await waitForExpect(async () => { - expect((await ItemRepository.findOneBy({ id: item.id }))?.path).toContain( - newParentItem.path, - ); - }); + // When the websocket is not received, indicates that the transaction + // is still in progress, the item's parent path should be root (undefined). + const itemBeforeUpdate = await ItemRepository.findOneBy({ id: item.id }); + if (!itemBeforeUpdate) { + fail('The item was not found in the repository.'); + } + expect(getParentFromPath(itemBeforeUpdate.path)).toBeUndefined(); await waitForExpect(() => { const [ownDelete, accessibleDelete] = itemUpdates; @@ -508,6 +523,14 @@ describe('Item websocket hooks', () => { AccessibleItemsEvent('delete', parseStringToDate(item) as Item), ); }); + + // When the websocket is received, indicates that the transaction + // is done, the item should contain the new parent path. + const itemAfterUpdate = await ItemRepository.findOneBy({ id: item.id }); + if (!itemAfterUpdate) { + fail('The item was not found in the repository.'); + } + expect(itemAfterUpdate.path).toContain(newParentItem.path); }); it('creator receives own items create update if new location is root of creator', async () => { @@ -526,18 +549,25 @@ describe('Item websocket hooks', () => { }); expect(response.statusCode).toBe(StatusCodes.ACCEPTED); - await waitForExpect(async () => { - expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).not.toContain( - oldParentItem.path, - ); - }); - - const moved = await ItemRepository.findOneBy({ id: childItem.id }); + // When the websocket is not received, indicates that the transaction + // is still in progress, the item should contain the old path. + expect((await ItemRepository.findOneBy({ id: childItem.id }))?.path).toContain( + oldParentItem.path, + ); - await waitForExpect(() => { + await waitForExpect(async () => { const [ownCreate] = itemUpdates; + const moved = await ItemRepository.findOneBy({ id: childItem.id }); expect(ownCreate).toMatchObject(OwnItemsEvent('create', parseStringToDate(moved) as Item)); }); + + // When the websocket is received, indicates that the transaction + // is done, the item's parent path should be root (undefined). + const itemAfterUpdate = await ItemRepository.findOneBy({ id: childItem.id }); + if (!itemAfterUpdate) { + fail('The item was not found in the repository.'); + } + expect(getParentFromPath(itemAfterUpdate.path)).toBeUndefined(); }); }); @@ -589,7 +619,11 @@ describe('Item websocket hooks', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('update', [item.id], { error: new Error('mock error') }), + ItemOpFeedbackEvent( + 'update', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); @@ -633,7 +667,11 @@ describe('Item websocket hooks', () => { await waitForExpect(() => { const [_ownUpdate, _accessibleUpdate, feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('delete', [item.id], { error: new Error('mock error') }), + ItemOpFeedbackEvent( + 'delete', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); @@ -685,7 +723,11 @@ describe('Item websocket hooks', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('move', [item.id], { error: new Error('mock error') }), + ItemOpFeedbackEvent( + 'move', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); @@ -735,7 +777,11 @@ describe('Item websocket hooks', () => { await waitForExpect(() => { const [feedbackUpdate] = memberUpdates; expect(feedbackUpdate).toMatchObject( - ItemOpFeedbackEvent('copy', [item.id], { error: new Error('mock error') }), + ItemOpFeedbackEvent( + 'copy', + [item.id], + ResultOfFactory.withError(new Error('mock error')), + ), ); }); }); diff --git a/src/services/item/types.ts b/src/services/item/types.ts index 10fb5e4961..49e3fda4ae 100644 --- a/src/services/item/types.ts +++ b/src/services/item/types.ts @@ -30,3 +30,67 @@ export type ItemChildrenParams = { ordered?: boolean; types?: UnionOfConst[]; }; + +export type PromiseRunnerResults = { + success: T[]; + failed: Error[]; +}; + +export class PromiseRunner { + private static transformPromiseResults = (results: PromiseSettledResult[]) => { + const success = results + .filter((result) => result.status === 'fulfilled') + .map((result) => (result as PromiseFulfilledResult).value); + + const failed = results + .filter((result) => result.status === 'rejected') + .map((result) => new Error((result as PromiseRejectedResult).reason)); + + return { success, failed }; + }; + + public static async inSeries( + promises: (() => Promise)[], + ): Promise> { + const success: PromiseRunnerResults['success'] = []; + const failed: PromiseRunnerResults['failed'] = []; + + const startTime = performance.now(); + + for (const promise of promises) { + try { + const result = await promise(); + success.push(result); + } catch (error) { + failed.push(error); + } + } + + const endTime = performance.now(); + console.log( + `Series terminated after ${endTime - startTime} ms for ${promises.length} Promises`, + startTime, + endTime, + ); + + return { success, failed }; + } + + public static async allSettled( + promises: (() => Promise)[], + ): Promise> { + const startTime = performance.now(); + + const results = await Promise.allSettled(promises.map((promise) => promise())); + const { success, failed } = PromiseRunner.transformPromiseResults(results); + + const endTime = performance.now(); + console.log( + `Concurrently terminated after ${endTime - startTime} ms for ${promises.length} Promises`, + startTime, + endTime, + ); + + return { success, failed }; + } +} diff --git a/src/services/item/ws/events.ts b/src/services/item/ws/events.ts index f302082c6c..e9fcf2f0db 100644 --- a/src/services/item/ws/events.ts +++ b/src/services/item/ws/events.ts @@ -130,18 +130,19 @@ export const SharedItemsEvent = (op: SharedItemsEvent['op'], item: Item): Shared item, }); +export const ResultOfFactory = { + withError: (e: Error) => ResultOfFactory.withErrors([e]), + withErrors: (errors: Error[]) => ({ data: {}, errors }), +}; + /** * Events from asynchronous background operations on given items */ -interface ItemOpFeedbackEvent { +export interface ItemOpFeedbackEventInterface { kind: 'feedback'; op: 'update' | 'delete' | 'move' | 'copy' | 'export' | 'recycle' | 'restore' | 'validate'; resource: Item['id'][]; - result: - | { - error: Error; - } - | ResultOf; + result: ResultOf; } /** @@ -153,15 +154,19 @@ interface ItemOpFeedbackEvent { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export const ItemOpFeedbackEvent = ( - op: ItemOpFeedbackEvent['op'], - resource: ItemOpFeedbackEvent['resource'], - result: ItemOpFeedbackEvent['result'], -): ItemOpFeedbackEvent => ({ + op: ItemOpFeedbackEventInterface['op'], + resource: ItemOpFeedbackEventInterface['resource'], + result: ItemOpFeedbackEventInterface['result'], +): ItemOpFeedbackEventInterface => ({ kind: 'feedback', op, resource, - result: result['error'] - ? // monkey patch because somehow JSON.stringify(e: Error) will always result in {} - { error: { name: result['error'].name, message: result['error'].message } } - : result, + result: { + data: result.data, + // monkey patch because JSON.stringify(e: Error) will always result in {} + errors: result.errors.map((e) => ({ + name: e.name, + message: e.message, + })), + }, }); diff --git a/src/services/item/ws/hooks.ts b/src/services/item/ws/hooks.ts index 3f4686706c..0cb3d5a613 100644 --- a/src/services/item/ws/hooks.ts +++ b/src/services/item/ws/hooks.ts @@ -70,23 +70,6 @@ function registerItemTopic(websockets: WebsocketService, itemService: ItemServic websockets.publish(itemTopic, parentId, ChildItemEvent('create', item)); } }); - - // on move item, notify: - // - parent of old location of deleted child - // - parent of new location of new child - itemService.hooks.setPostHook( - 'move', - async (actor, repositories, { sourceParentId, source, destination }) => { - if (sourceParentId !== undefined) { - websockets.publish(itemTopic, sourceParentId, ChildItemEvent('delete', source)); - } - - const destParentId = getParentFromPath(destination.path); - if (destParentId) { - websockets.publish(itemTopic, destParentId, ChildItemEvent('create', destination)); - } - }, - ); } /** @@ -189,43 +172,6 @@ function registerMemberItemsTopic(websockets: WebsocketService, itemService: Ite websockets.publish(memberItemsTopic, item.creator.id, AccessibleItemsEvent('create', item)); } }); - - // on move item: - // - notify own items of creator of delete IF old location was root - // - notify own items of creator of create IF new location is root - itemService.hooks.setPostHook( - 'move', - async (actor, repositories, { source, destination, sourceParentId }) => { - if (sourceParentId === undefined && source.creator) { - // root item, notify creator - - // todo: remove own when we don't use own anymore - websockets.publish(memberItemsTopic, source.creator.id, OwnItemsEvent('delete', source)); - - websockets.publish( - memberItemsTopic, - source.creator.id, - AccessibleItemsEvent('delete', source), - ); - } - - const destParentId = getParentFromPath(destination.path); - if (destParentId === undefined && destination.creator) { - // root item, notify creator - // todo: remove own when we don't use own anymore - websockets.publish( - memberItemsTopic, - destination.creator.id, - OwnItemsEvent('create', destination), - ); - websockets.publish( - memberItemsTopic, - destination.creator.id, - AccessibleItemsEvent('create', destination), - ); - } - }, - ); } /** diff --git a/src/services/item/ws/services.ts b/src/services/item/ws/services.ts new file mode 100644 index 0000000000..32ba07813e --- /dev/null +++ b/src/services/item/ws/services.ts @@ -0,0 +1,113 @@ +import { FastifyBaseLogger } from 'fastify'; + +import { getParentFromPath } from '@graasp/sdk'; + +import { WebsocketService } from '../../websockets/ws-service'; +import { Item } from '../entities/Item'; +import { PromiseRunnerResults } from '../types'; +import { + AccessibleItemsEvent, + ChildItemEvent, + ItemOpFeedbackEvent, + ItemOpFeedbackEventInterface, + OwnItemsEvent, + itemTopic, + memberItemsTopic, +} from './events'; + +type MoveParams = { + source: Item; + destination: Item; + sourceParentId?: string; +}; + +type PublishFeedbackParams = { + results: PromiseRunnerResults; + itemIds: string[]; + log: FastifyBaseLogger; + memberId: string; + feedbackOp: ItemOpFeedbackEventInterface['op']; +}; + +export class ItemWebsocketsService { + private websockets: WebsocketService; + + constructor(websockets: WebsocketService) { + this.websockets = websockets; + } + + // TODO: update this to send only one ws ? like a warning or success or error ? + private publishFeedback({ results, itemIds, log, memberId, feedbackOp }: PublishFeedbackParams) { + const { success, failed } = results; + + this.websockets.publish( + memberItemsTopic, + memberId, + ItemOpFeedbackEvent(feedbackOp, itemIds, { + data: Object.fromEntries(success.map((i) => [i.id, i])), + errors: failed, + }), + ); + + if (failed.length) { + log.error(failed); + } + } + + public publishTopicsForMove({ source, destination, sourceParentId }: MoveParams) { + const destParentId = getParentFromPath(destination.path); + + // on move item: + // - notify own items of creator of delete IF old location was root + // - notify own items of creator of create IF new location is root + if (sourceParentId === undefined && source.creator) { + // root item, notify creator + // todo: remove own when we don't use own anymore + this.websockets.publish(memberItemsTopic, source.creator.id, OwnItemsEvent('delete', source)); + this.websockets.publish( + memberItemsTopic, + source.creator.id, + AccessibleItemsEvent('delete', source), + ); + } + if (destParentId === undefined && destination.creator) { + // root item, notify creator + // todo: remove own when we don't use own anymore + this.websockets.publish( + memberItemsTopic, + destination.creator.id, + OwnItemsEvent('create', destination), + ); + this.websockets.publish( + memberItemsTopic, + destination.creator.id, + AccessibleItemsEvent('create', destination), + ); + } + + // on move item, notify: + // - parent of old location of deleted child + // - parent of new location of new child + if (sourceParentId !== undefined) { + this.websockets.publish(itemTopic, sourceParentId, ChildItemEvent('delete', source)); + } + if (destParentId) { + this.websockets.publish(itemTopic, destParentId, ChildItemEvent('create', destination)); + } + } + + public publishFeedbacksForMove({ + results, + itemIds, + log, + memberId, + }: Omit) { + this.publishFeedback({ + results, + itemIds, + log, + memberId, + feedbackOp: 'move', + }); + } +}