From e54b14f0516f35a6cb7cea3bc6371c31edeac16e Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 16 Jul 2026 11:47:25 +0300 Subject: [PATCH] Extend Stela conversion in getWithChildren and record mapping Map archiveNumber, folderLinkId, parentFolderLinkId, the breadcrumb path arrays, and created/updated timestamps from the Stela folder response. Resolve missing folderIds through the legacy /folder/get endpoint, fetch the folder and its children in parallel, and convert failures into v1-shaped error responses so existing error handlers keep working. Hardcode accessRole to owner on both converted folders and records, since the backend removed the item-level field; without it, permission gates like the sidebar share button treated records as inaccessible. Issue: PER-10476 --- .../shared/services/api/folder.repo.spec.ts | 191 +++++++++++++++++- src/app/shared/services/api/folder.repo.ts | 116 +++++++---- .../shared/services/api/record.repo.spec.ts | 11 + src/app/shared/services/api/record.repo.ts | 4 + 4 files changed, 280 insertions(+), 42 deletions(-) diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index bea2d65f3..13f57ed26 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -1,10 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { FolderVO } from '@models/index'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { ShareLink } from '@root/app/share-links/models/share-link'; import { HttpV2Service } from '../http-v2/http-v2.service'; import { HttpService } from '../http/http.service'; -import { FolderRepo } from './folder.repo'; +import { FolderRepo, FolderResponse } from './folder.repo'; const emptyResponse = { items: [] }; const fakeFolderResponse = { @@ -55,6 +55,42 @@ const fakeChildrenResponse = { ], }; +const buildStelaFolderResponse = (overrides: Record = {}) => ({ + items: [ + { + folderId: '42', + archiveNumber: 'ARCH-001', + archive: { id: 'arch-id', name: 'Test Archive' }, + folderLinkId: 100, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-06-01T00:00:00Z', + description: 'Test', + displayTimestamp: '2024-01-01T00:00:00Z', + displayEndTimestamp: null, + displayName: 'Test Folder', + downloadName: 'Test Folder', + imageRatio: 1, + paths: { + names: ['My Files', 'Test Folder'], + folderLinkIds: ['55', '100'], + archiveNumbers: ['ARCH-000', 'ARCH-001'], + }, + publicAt: null, + sort: null, + thumbnailUrls: null, + type: 'type.folder.generic', + status: 'status.generic.ok', + view: 'grid', + size: 0, + location: null, + parentFolder: { id: 'parent-id', parentFolderLinkId: 55 }, + shares: null, + tags: null, + ...overrides, + }, + ], +}); + describe('Folder repo', () => { let folderRepo: FolderRepo; let httpSpy: jasmine.SpyObj; @@ -158,10 +194,13 @@ describe('Folder repo', () => { it('should get folder with children using fallback to auth token', async () => { const mockFolderVO = { folderId: 42 } as FolderVO; + // The folder and children requests run in parallel, so the call order + // is: folder (share token), children (share token), then the + // auth-token fallbacks in the same order. httpV2Spy.get.and.returnValues( of([emptyResponse]), + of([{}]), of([fakeFolderResponse]), - of([emptyResponse]), of([fakeChildrenResponse]), ); @@ -184,6 +223,152 @@ describe('Folder repo', () => { expect(result.Results[0].data[0].FolderVO).toBeDefined(); }); + describe('getWithChildren error handling', () => { + it('should return a FolderResponse with isSuccessful falsy when the Stela API throws', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + const apiError = { error: { error: 'Internal server error' } }; + + httpV2Spy.get.and.returnValue( + new Observable((subscriber) => subscriber.error(apiError)), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.isSuccessful).toBeFalsy(); + }); + + it('should surface the error message from err.error.error via getMessage()', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + const apiError = { error: { error: 'Folder not found' } }; + + httpV2Spy.get.and.returnValue( + new Observable((subscriber) => subscriber.error(apiError)), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.getMessage()).toBe('Folder not found'); + }); + + it('should return an empty error message when err.error.error is absent', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + + httpV2Spy.get.and.returnValue( + new Observable((subscriber) => subscriber.error({})), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.getMessage()).toBeUndefined(); + }); + + it('should surface the message of internally thrown errors via getMessage()', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + + // Both the folder and children endpoints return empty results, + // so getWithChildren throws its internal "no folder" Error. + httpV2Spy.get.and.returnValue(of([{ items: [] }])); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.getMessage()).toBe( + 'No folder returned from getStelaFolders', + ); + }); + }); + + describe('resolveFolderId', () => { + it('should not call the legacy /folder/get endpoint when folderId is already present', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + + httpV2Spy.get.and.returnValues( + of([buildStelaFolderResponse()]), + of([{ items: [] }]), + ); + + await folderRepo.getWithChildren([folderVO]); + + expect(httpSpy.sendRequestPromise).not.toHaveBeenCalled(); + }); + + it('should call legacy /folder/get to resolve folderId when it is missing', async () => { + const folderVO = new FolderVO({ + archiveNbr: '0001-0001', + folder_linkId: 123, + }); + + const resolvedFolderResponse = new FolderResponse({ + isSuccessful: true, + Results: [ + { + data: [{ FolderVO: { folderId: 99 } }], + status: true, + message: ['OK'], + resultDT: new Date().toISOString(), + createdDT: null, + updatedDT: null, + }, + ], + }); + + httpSpy.sendRequestPromise.and.resolveTo(resolvedFolderResponse); + httpV2Spy.get.and.returnValues( + of([buildStelaFolderResponse({ folderId: '99' })]), + of([{ items: [] }]), + ); + + await folderRepo.getWithChildren([folderVO]); + + expect(httpSpy.sendRequestPromise).toHaveBeenCalledWith( + '/folder/get', + jasmine.any(Array), + jasmine.any(Object), + ); + + expect(httpV2Spy.get).toHaveBeenCalledWith('v2/folder', { + folderIds: [99], + }); + }); + }); + + describe('convertStelaFolderToFolderVO mapping', () => { + const getConvertedFolder = async () => { + const folderVO = new FolderVO({ folderId: 42 }); + httpV2Spy.get.and.returnValues( + of([buildStelaFolderResponse()]), + of([{ items: [] }]), + ); + const result = await folderRepo.getWithChildren([folderVO]); + return result.getFolderVO(true); + }; + + it('should map parentFolder_linkId from the parentFolder object', async () => { + const folder = await getConvertedFolder(); + + expect(folder.parentFolder_linkId).toBe(55); + }); + + it('should map the folder path arrays for breadcrumbs', async () => { + const folder = await getConvertedFolder(); + + expect(folder.pathAsText).toEqual(['My Files', 'Test Folder']); + expect(folder.pathAsFolder_linkId).toEqual([55, 100]); + expect(folder.pathAsArchiveNbr).toEqual(['ARCH-000', 'ARCH-001']); + }); + + it('should leave folder_linkType undefined since the backend omits it', async () => { + const folder = await getConvertedFolder(); + + expect(folder.folder_linkType).toBeUndefined(); + }); + + it('should hardcode accessRole to owner', async () => { + const folder = await getConvertedFolder(); + + expect(folder.accessRole).toBe('access.role.owner'); + }); + }); + describe('getFolderShareLink', () => { const mockShareLink: ShareLink = { id: 'link1', diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 87ffa4ae3..5d9662eb7 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -48,6 +48,7 @@ interface StelaFolder { location: StelaLocation; parentFolder: { id: string; + parentFolderLinkId: number; }; shares: Array; tags: Array; @@ -55,6 +56,8 @@ interface StelaFolder { id: string; name: string; }; + archiveNumber: string; + folderLinkId: number; createdAt: string; updatedAt: string; description: string; @@ -66,6 +69,8 @@ interface StelaFolder { imageRatio: number; paths: { names: string[]; + folderLinkIds: string[]; + archiveNumbers: string[]; }; publicAt: string; sort: string; @@ -103,6 +108,9 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { ...stelaFolder, folderId: stelaFolder.folderId, archiveId: stelaFolder.archive?.id, + archiveNbr: stelaFolder.archiveNumber, + folder_linkId: stelaFolder.folderLinkId, + parentFolder_linkId: stelaFolder.parentFolder?.parentFolderLinkId, displayName: stelaFolder.displayName, displayDT: stelaFolder.displayTimestamp, displayEndDT: stelaFolder.displayEndTimestamp, @@ -126,9 +134,13 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { thumbnail256: stelaFolder.thumbnailUrls?.['256'], thumbnail256CloudPath: stelaFolder.thumbnailUrls?.['256'], status: stelaFolder.status, + createdDT: stelaFolder.createdAt, + updatedDT: stelaFolder.updatedAt, publicDT: stelaFolder.publicAt, parentFolderId: stelaFolder.parentFolder?.id, pathAsText: stelaFolder.paths?.names, + pathAsFolder_linkId: stelaFolder.paths?.folderLinkIds?.map(Number), + pathAsArchiveNbr: stelaFolder.paths?.archiveNumbers, ParentFolderVOs: [new FolderVO({ folderId: stelaFolder.parentFolder?.id })], ChildFolderVOs: childFolderVOs, RecordVOs: childRecordVOs, @@ -139,6 +151,9 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { ), ChildItemVOs: [...childRecordVOs, ...childFolderVOs], ShareVOs: (stelaFolder.shares ?? []).map(convertStelaSharetoShareVO), + // accessRole is intentionally always owner: the backend removed item-level accessRole + // because all non-owner values were deprecated in 2020. Real access is on ShareVOs. + accessRole: 'access.role.owner', isFolder: true, }); }; @@ -303,51 +318,74 @@ export class FolderRepo extends BaseRepo { return response[0].items; } + private async resolveFolderId(folderVO: FolderVO): Promise { + if (folderVO.folderId) { + return folderVO; + } + const response = await this.get([folderVO]); + const resolvedFolder = response.getFolderVO(); + return new FolderVO({ ...folderVO, folderId: resolvedFolder.folderId }); + } + public async getWithChildren( folderVOs: FolderVO[], shareToken: string = null, ): Promise { - // Stela has two separate endpoints -- one for loading the folder, one for loading the children. - const requests = folderVOs.map(async (folderVO) => { - const stelaFolders = await this.getStelaFolders([folderVO], shareToken); - const stelaFolderChildren = await this.getStelaFolderChildren( - folderVO, - shareToken, + try { + // Stela has two separate endpoints -- one for loading the folder, one for loading the children. + const requests = folderVOs.map(async (folderVO) => { + const resolvedFolderVO = await this.resolveFolderId(folderVO); + const [stelaFolders, stelaFolderChildren] = await Promise.all([ + this.getStelaFolders([resolvedFolderVO], shareToken), + this.getStelaFolderChildren(resolvedFolderVO, shareToken), + ]); + const stelaFolder = stelaFolders[0]; + if (!stelaFolder) { + throw new Error('No folder returned from getStelaFolders'); + } + return { + ...stelaFolder, + children: stelaFolderChildren, + }; + }); + + const stelaFolders = (await Promise.all(requests)).flat(); + + // We need the `Results` to look the way v1 results look, for now. + const simulatedV1FolderResponseResults = stelaFolders.map( + (stelaFolder) => ({ + data: [ + { + FolderVO: convertStelaFolderToFolderVO(stelaFolder), + }, + ], + message: ['Folder retrieved'], + status: true, + resultDT: new Date().toISOString(), + createdDT: null, + updatedDT: null, + }), ); - const stelaFolder = stelaFolders[0]; - if (!stelaFolder) { - throw new Error('No folder returned from getStelaFolders'); - } - return { - ...stelaFolder, - children: stelaFolderChildren, - }; - }); - - const stelaFolders = (await Promise.all(requests)).flat(); - - // We need the `Results` to look the way v1 results look, for now. - const simulatedV1FolderResponseResults = stelaFolders.map( - (stelaFolder) => ({ - data: [ - { - FolderVO: convertStelaFolderToFolderVO(stelaFolder), - }, - ], - message: ['Folder retrieved'], - status: true, - resultDT: new Date().toISOString(), - createdDT: null, - updatedDT: null, - }), - ); - const folderResponse = new FolderResponse({ - isSuccessful: true, - isSystemUp: true, - Results: simulatedV1FolderResponseResults, - }); - return folderResponse; + const folderResponse = new FolderResponse({ + isSuccessful: true, + isSystemUp: true, + Results: simulatedV1FolderResponseResults, + }); + return folderResponse; + } catch (err) { + // We need the error to look the way v1 errors look too, + // Changing all the error handlers would be errror prone + const errorFolderResponse = new FolderResponse(); + errorFolderResponse.Results = [ + { + // Stela API errors carry the message in err.error.error; + // internally thrown Errors carry it in err.message. + message: [err?.error?.error ?? err?.message], + }, + ]; + return errorFolderResponse; + } } public navigate(folderVO: FolderVO): Observable { diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index 4cb1ebedc..c0e71e63a 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -447,5 +447,16 @@ describe('RecordRepo', () => { expect(record.displayTime).toBeUndefined(); }); + + it('should hardcode accessRole to owner, matching the folder conversion', () => { + // The backend omits item-level accessRole; UI permission gates + // (e.g. the sidebar share button) read it and treat a missing + // value as no access. + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + } as any); + + expect(record.accessRole).toBe('access.role.owner'); + }); }); }); diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index d132d7121..cf5864d53 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -222,6 +222,10 @@ export const convertStelaRecordToRecordVO = ( timeZoneId: CENTRAL_TIMEZONE_VO.timeZoneId, TimezoneVO: CENTRAL_TIMEZONE_VO, ShareVOs: (stelaRecord.shares ?? []).map(convertStelaSharetoShareVO), + // accessRole is intentionally always owner, matching the folder + // conversion: the backend removed item-level accessRole because all + // non-owner values were deprecated in 2020. Real access is on ShareVOs. + accessRole: 'access.role.owner', }); export class RecordRepo extends BaseRepo {