diff --git a/src/app/core/resolves/lean-folder-resolve.service.spec.ts b/src/app/core/resolves/lean-folder-resolve.service.spec.ts new file mode 100644 index 000000000..8972838e5 --- /dev/null +++ b/src/app/core/resolves/lean-folder-resolve.service.spec.ts @@ -0,0 +1,287 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { ApiService } from '@shared/services/api/api.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { FolderResponse } from '@shared/services/api/index.repo'; +import { FolderVO } from '@root/app/models'; +import { LeanFolderResolveService } from './lean-folder-resolve.service'; + +const buildMockFolderResponse = (folderVO: Partial = {}) => + new FolderResponse({ + isSuccessful: true, + Results: [ + { + data: [ + { + FolderVO: new FolderVO({ + folderId: 'folder-1', + type: 'type.folder.generic', + ChildItemVOs: [], + ...folderVO, + }), + }, + ], + status: true, + message: ['OK'], + resultDT: new Date().toISOString(), + createdDT: null, + updatedDT: null, + }, + ], + }); + +const buildRoute = ( + params: Record = {}, + parentData: Record = {}, +): ActivatedRouteSnapshot => + ({ + params, + parent: { data: parentData }, + }) as any; + +const buildState = (url: string): RouterStateSnapshot => ({ url }) as any; + +describe('LeanFolderResolveService', () => { + let service: LeanFolderResolveService; + let getWithChildrenSpy: jasmine.Spy; + let accountService: { getRootFolder: jasmine.Spy; logOut: jasmine.Spy }; + let messageService: { showError: jasmine.Spy }; + let router: { navigate: jasmine.Spy }; + + const privateFolder = new FolderVO({ + folderId: 'private-root', + folder_linkId: 1, + archiveNbr: '0001-0001', + type: 'type.folder.root.private', + }); + + const appsFolder = new FolderVO({ + folderId: 'apps-root', + folder_linkId: 2, + archiveNbr: '0002-0001', + type: 'type.folder.root.app', + }); + + beforeEach(() => { + getWithChildrenSpy = jasmine + .createSpy('getWithChildren') + .and.resolveTo(buildMockFolderResponse()); + + accountService = { + getRootFolder: jasmine.createSpy('getRootFolder').and.returnValue( + new FolderVO({ + ChildItemVOs: [privateFolder, appsFolder], + }), + ), + logOut: jasmine.createSpy('logOut').and.resolveTo(undefined), + }; + + messageService = { showError: jasmine.createSpy('showError') }; + router = { navigate: jasmine.createSpy('navigate') }; + + TestBed.configureTestingModule({ + providers: [ + LeanFolderResolveService, + { + provide: ApiService, + useValue: { folder: { getWithChildren: getWithChildrenSpy } }, + }, + { provide: AccountService, useValue: accountService }, + { provide: MessageService, useValue: messageService }, + { provide: Router, useValue: router }, + ], + }); + + service = TestBed.inject(LeanFolderResolveService); + }); + + describe('route branches', () => { + it('should call getWithChildren with a FolderVO built from URL params', async () => { + const route = buildRoute({ + archiveNbr: '0001-0001', + folderLinkId: '123', + }); + const state = buildState('/private/0001-0001/123'); + + await service.resolve(route, state); + + const calledWith = getWithChildrenSpy.calls.mostRecent() + .args[0][0] as FolderVO; + + expect(calledWith.archiveNbr).toBe('0001-0001'); + expect(String(calledWith.folder_linkId)).toBe('123'); + }); + + it('should call getWithChildren with the apps folder when url is /apps', async () => { + const route = buildRoute({}); + const state = buildState('/apps'); + + await service.resolve(route, state); + + const calledWith = getWithChildrenSpy.calls.mostRecent() + .args[0][0] as FolderVO; + + expect(calledWith.type).toBe('type.folder.root.app'); + }); + + it('should call getWithChildren with the shared folder when in a /share/ route with a folder', async () => { + const sharedFolder = new FolderVO({ + folderId: 'shared-folder', + type: 'type.folder.generic', + }); + const route = buildRoute( + {}, + { sharePreviewVO: { FolderVO: sharedFolder, RecordVO: null } }, + ); + const state = buildState('/share/token123'); + + await service.resolve(route, state); + + const calledWith = getWithChildrenSpy.calls.mostRecent() + .args[0][0] as FolderVO; + + expect(calledWith.folderId).toBe('shared-folder'); + }); + + it('should return the folder directly without an API call when in a /share/ route with a record', async () => { + const currentFolder = new FolderVO({ + folderId: 'current', + pathAsArchiveNbr: ['root'], + pathAsText: ['My Files'], + pathAsFolder_linkId: [0], + ChildItemVOs: [], + }); + const sharedRecord = { recordId: 999, isRecord: true }; + const route = buildRoute( + {}, + { + sharePreviewVO: { FolderVO: null, RecordVO: sharedRecord }, + currentFolder, + }, + ); + const state = buildState('/share/token123'); + + const result = await service.resolve(route, state); + + expect(getWithChildrenSpy).not.toHaveBeenCalled(); + expect(result.ChildItemVOs).toContain(sharedRecord as any); + }); + + it('should call getWithChildren with the private root folder for the default route', async () => { + const route = buildRoute({}); + const state = buildState('/private'); + + await service.resolve(route, state); + + const calledWith = getWithChildrenSpy.calls.mostRecent() + .args[0][0] as FolderVO; + + expect(calledWith.type).toBe('type.folder.root.private'); + }); + + it('should return the FolderVO from the response', async () => { + getWithChildrenSpy.and.resolveTo( + buildMockFolderResponse({ folderId: 'resolved' }), + ); + + const route = buildRoute({}); + const state = buildState('/private'); + + const result = await service.resolve(route, state); + + expect(result.folderId).toBe('resolved'); + }); + }); + + describe('error handling', () => { + // getWithChildren never rejects: on failure it resolves with an + // error-shaped FolderResponse (isSuccessful falsy, no data, message + // from the API error). This mirrors that shape. + const buildErrorFolderResponse = (errorMessage: string) => { + const errorResponse = new FolderResponse(); + errorResponse.Results = [{ message: [errorMessage] }]; + return errorResponse; + }; + + it('should show an error message when getWithChildren returns an unsuccessful response', async () => { + getWithChildrenSpy.and.resolveTo( + buildErrorFolderResponse('Folder not found'), + ); + + const route = buildRoute({}); + const state = buildState('/private'); + + await service.resolve(route, state).catch(() => {}); + + expect(messageService.showError).toHaveBeenCalledWith({ + message: 'Folder not found', + translate: true, + }); + }); + + it('should log out and navigate to /login when a root folder fails', async () => { + // Default branch → privateFolder with type 'type.folder.root.private' + // which includes 'root' → logOut is called + getWithChildrenSpy.and.resolveTo( + buildErrorFolderResponse('Folder not found'), + ); + + const route = buildRoute({}); + const state = buildState('/private'); + + await service.resolve(route, state).catch(() => {}); + + expect(accountService.logOut).toHaveBeenCalled(); + }); + + it('should log out and navigate to /login when the apps root folder fails', async () => { + // Apps branch → appsFolder with type 'type.folder.root.app' + // which also includes 'root' → logOut is called, not navigate(['/apps']) + getWithChildrenSpy.and.resolveTo( + buildErrorFolderResponse('Folder not found'), + ); + + const route = buildRoute({}); + const state = buildState('/apps'); + + await service.resolve(route, state).catch(() => {}); + + expect(accountService.logOut).toHaveBeenCalled(); + }); + + it('should navigate to /private when a non-root shared folder fails', async () => { + // Share branch → sharedFolder with type 'type.folder.generic' (no 'root') + // state.url does not include 'apps' → navigate(['/private']) + getWithChildrenSpy.and.resolveTo( + buildErrorFolderResponse('Folder not found'), + ); + + const sharedFolder = new FolderVO({ + folderId: 'shared-folder', + type: 'type.folder.generic', + }); + const route = buildRoute( + {}, + { sharePreviewVO: { FolderVO: sharedFolder, RecordVO: null } }, + ); + const state = buildState('/share/token123'); + + await service.resolve(route, state).catch(() => {}); + + expect(router.navigate).toHaveBeenCalledWith(['/private']); + }); + + it('should return a rejected promise when the API fails', async () => { + getWithChildrenSpy.and.resolveTo( + buildErrorFolderResponse('Folder not found'), + ); + + const route = buildRoute({}); + const state = buildState('/private'); + + await expectAsync(service.resolve(route, state)).toBeRejected(); + }); + }); +}); diff --git a/src/app/core/resolves/lean-folder-resolve.service.ts b/src/app/core/resolves/lean-folder-resolve.service.ts index 72bbb904a..e2ac13d32 100644 --- a/src/app/core/resolves/lean-folder-resolve.service.ts +++ b/src/app/core/resolves/lean-folder-resolve.service.ts @@ -4,8 +4,6 @@ import { RouterStateSnapshot, Router, } from '@angular/router'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; import { find, cloneDeep } from 'lodash'; import { ApiService } from '@shared/services/api/api.service'; import { AccountService } from '@shared/services/account/account.service'; @@ -24,10 +22,10 @@ export class LeanFolderResolveService { private router: Router, ) {} - resolve( + async resolve( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, - ): Observable | Promise { + ): Promise { let targetFolder; if (route.params.archiveNbr && route.params.folderLinkId) { @@ -51,7 +49,7 @@ export class LeanFolderResolveService { folder.pathAsText.unshift('Shares', 'Record'); folder.pathAsFolder_linkId.unshift(0, 0); folder.ChildItemVOs = [sharedRecord]; - return Promise.resolve(folder); + return folder; } } else { const myFiles = find(this.accountService.getRootFolder().ChildItemVOs, { @@ -60,38 +58,39 @@ export class LeanFolderResolveService { targetFolder = new FolderVO(myFiles); } - return this.api.folder - .navigateLean(targetFolder) - .pipe( - map((response: FolderResponse) => { - if (!response.isSuccessful) { - throw response; - } + try { + const response: FolderResponse = await this.api.folder.getWithChildren([ + targetFolder, + ]); - return response.getFolderVO(true); - }), - ) - .toPromise() - .catch(async (response: FolderResponse) => { - this.message.showError({ - message: response.getMessage(), - translate: true, - }); - if (targetFolder.type.includes('root')) { - this.accountService - .logOut() - .then(() => { - this.router.navigate(['/login']); - }) - .catch(() => { - this.router.navigate(['/login']); - }); - } else if (state.url.includes('apps')) { - this.router.navigate(['/apps']); - } else { - this.router.navigate(['/private']); - } - return await Promise.reject(false); + // getWithChildren resolves with an error-shaped FolderResponse instead + // of rejecting, so failures must be detected here and thrown to reach + // the catch block below. + if (!response.isSuccessful) { + throw response; + } + + return response.getFolderVO(true); + } catch (response) { + this.message.showError({ + message: response.getMessage(), + translate: true, }); + if (targetFolder.type.includes('root')) { + this.accountService + .logOut() + .then(() => { + this.router.navigate(['/login']); + }) + .catch(() => { + this.router.navigate(['/login']); + }); + } else if (state.url.includes('apps')) { + this.router.navigate(['/apps']); + } else { + this.router.navigate(['/private']); + } + return await Promise.reject(false); + } } } diff --git a/src/app/filesystem/filesystem-api.service.spec.ts b/src/app/filesystem/filesystem-api.service.spec.ts index 1716643bf..070d3e8de 100644 --- a/src/app/filesystem/filesystem-api.service.spec.ts +++ b/src/app/filesystem/filesystem-api.service.spec.ts @@ -3,7 +3,6 @@ import { FolderResponse } from '@shared/services/api/folder.repo'; import { FolderVO } from '@models/index'; import { DataStatus } from '@models/data-status.enum'; import { ApiService } from '@shared/services/api/api.service'; -import { of } from 'rxjs'; import { ShareLinksService } from '../share-links/services/share-links.service'; import { FilesystemApiService } from './filesystem-api.service'; @@ -11,7 +10,7 @@ const folderId = 42; const mockFolderVO = { folderId, - displayName: 'Unlisted Folder', + displayName: 'Test Folder', ChildItemVOs: [], dataStatus: DataStatus.Lean, }; @@ -20,11 +19,12 @@ const mockSuccessResponse = new FolderResponse({ isSuccessful: true, Results: [ { - data: [ - { - FolderVO: mockFolderVO, - }, - ], + data: [{ FolderVO: mockFolderVO }], + status: true, + message: ['OK'], + resultDT: new Date().toISOString(), + createdDT: null, + updatedDT: null, }, ], }); @@ -46,9 +46,6 @@ describe('FilesystemApiService', () => { getWithChildren: jasmine .createSpy('getWithChildren') .and.returnValue(Promise.resolve(mockSuccessResponse)), - navigateLean: jasmine - .createSpy('navigateLean') - .and.returnValue(of(mockSuccessResponse)), }, }; @@ -72,21 +69,21 @@ describe('FilesystemApiService', () => { expect(service).toBeTruthy(); }); - it('should navigate using navigateLean', async () => { + it('should navigate using getWithChildren with null shareToken when not in an unlisted share', async () => { shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); const folder = await service.navigate({ folderId }); - expect(mockApiService.folder.navigateLean).toHaveBeenCalledWith( - jasmine.any(FolderVO), + expect(mockApiService.folder.getWithChildren).toHaveBeenCalledWith( + [jasmine.any(FolderVO)], + null, ); expect(folder.folderId).toBe(folderId); - expect(folder.displayName).toBe('Unlisted Folder'); expect(folder.dataStatus).toBe(DataStatus.Lean); }); - it('should navigate using getWithChildren when in unlisted share', async () => { + it('should navigate using getWithChildren with shareToken when in an unlisted share', async () => { shareLinksServiceSpy.isUnlistedShare.and.resolveTo(true); shareLinksServiceSpy.currentShareToken = 'mock-token'; @@ -98,14 +95,13 @@ describe('FilesystemApiService', () => { ); expect(folder.folderId).toBe(folderId); - expect(folder.displayName).toBe('Unlisted Folder'); expect(folder.dataStatus).toBe(DataStatus.Lean); }); - it('should throw FolderResponse error if response is unsuccessful', async () => { + it('should throw when the response is unsuccessful', async () => { shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); - mockApiService.folder.navigateLean.and.returnValue( - of(mockUnsuccessfulResponse), + mockApiService.folder.getWithChildren.and.returnValue( + Promise.resolve(mockUnsuccessfulResponse), ); try { diff --git a/src/app/filesystem/filesystem-api.service.ts b/src/app/filesystem/filesystem-api.service.ts index 2026554df..c384702e9 100644 --- a/src/app/filesystem/filesystem-api.service.ts +++ b/src/app/filesystem/filesystem-api.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; import { FolderVO, RecordVO } from '@models/index'; import { ApiService } from '@shared/services/api/api.service'; @@ -24,17 +23,13 @@ export class FilesystemApiService implements FilesystemApi { public async navigate(folder: FolderIdentifier): Promise { const isUnlistedShare = await this.shareLinksService.isUnlistedShare(); - let response: FolderResponse = null; - if (isUnlistedShare) { - response = await this.api.folder.getWithChildren( - [new FolderVO(folder)], - this.shareLinksService.currentShareToken, - ); - } else { - response = await firstValueFrom( - this.api.folder.navigateLean(new FolderVO(folder)), - ); - } + const shareToken = isUnlistedShare + ? this.shareLinksService.currentShareToken + : null; + const response: FolderResponse = await this.api.folder.getWithChildren( + [new FolderVO(folder)], + shareToken, + ); if (!response.isSuccessful) { throw response; } 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 { diff --git a/src/app/views/components/timeline-view/timeline-view.component.ts b/src/app/views/components/timeline-view/timeline-view.component.ts index c67f46154..4ae0c9f03 100644 --- a/src/app/views/components/timeline-view/timeline-view.component.ts +++ b/src/app/views/components/timeline-view/timeline-view.component.ts @@ -485,9 +485,7 @@ export class TimelineViewComponent implements OnInit, AfterViewInit, OnDestroy { if (folder.isFetching) { await folder.fetched; } - const folderResponse = await this.api.folder - .navigateLean(folder) - .toPromise(); + const folderResponse = await this.api.folder.getWithChildren([folder]); this.dataService.setCurrentFolder(folderResponse.getFolderVO(true)); this.isNavigating = false; }