From f6a62ae94b0d3e424628e294a6825b4c1dfce565 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 18 Aug 2026 10:39:59 +0300 Subject: [PATCH] Map the stela accessRole values to the old v1 shape Until we will be able to change all the mappings in the app to the new version from stela, we have to make sure that we make all stela properties backwards compatible, so the web-app does not break. In the case of the accessRole, up until now, because it was omitted in the stela responses, it was always overriden by values from the old backend. But now, the accessRole comes back from stela as well, the web-app recognises the property, but it breaks because it does not recognise the value. Mapping it to the old version solves the issue. Issue: PER-10758 --- src/app/models/access-role.spec.ts | 69 +++++++++++++++++++ src/app/models/access-role.ts | 40 +++++++++++ .../shared/services/api/folder.repo.spec.ts | 62 +++++++++++++++++ src/app/shared/services/api/folder.repo.ts | 6 ++ .../shared/services/api/record.repo.spec.ts | 42 +++++++++++ src/app/shared/services/api/record.repo.ts | 10 ++- 6 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/app/models/access-role.spec.ts diff --git a/src/app/models/access-role.spec.ts b/src/app/models/access-role.spec.ts new file mode 100644 index 000000000..107a7311c --- /dev/null +++ b/src/app/models/access-role.spec.ts @@ -0,0 +1,69 @@ +import { + ARCHIVE_MEMBERSHIP_ROLE_TO_ACCESS_ROLE, + getAccessRoleFromArchiveMembershipRole, + getAccessAsEnum, + type ArchiveMembershipRoleType, +} from './access-role'; + +describe('getAccessRoleFromArchiveMembershipRole', () => { + const expectedTranslations: Array<[ArchiveMembershipRoleType, string]> = [ + ['contributor', 'access.role.contributor'], + ['curator', 'access.role.curator'], + ['editor', 'access.role.editor'], + ['manager', 'access.role.manager'], + ['owner', 'access.role.owner'], + ['viewer', 'access.role.viewer'], + ]; + + expectedTranslations.forEach( + ([archiveMembershipRole, expectedAccessRole]) => { + it(`should translate ${archiveMembershipRole}`, () => { + expect( + getAccessRoleFromArchiveMembershipRole(archiveMembershipRole), + ).toBe(expectedAccessRole); + }); + }, + ); + + it('should cover every role Stela can send', () => { + expect(Object.keys(ARCHIVE_MEMBERSHIP_ROLE_TO_ACCESS_ROLE).length).toBe( + expectedTranslations.length, + ); + }); + + it('should produce roles that getAccessAsEnum understands', () => { + expectedTranslations.forEach(([archiveMembershipRole]) => { + const accessRole = getAccessRoleFromArchiveMembershipRole( + archiveMembershipRole, + ); + + expect(getAccessAsEnum(accessRole)).toBeDefined(); + }); + }); + + it('should translate manager to manager, not the curator that PERMISSIONS_LEVEL_TO_ACCESS_ROLE maps it to', () => { + expect(getAccessRoleFromArchiveMembershipRole('manager')).toBe( + 'access.role.manager', + ); + }); + + it('should return undefined rather than guess at an unknown role', () => { + expect( + getAccessRoleFromArchiveMembershipRole( + 'wizard' as ArchiveMembershipRoleType, + ), + ).toBeUndefined(); + }); + + it('should return undefined when no role is given', () => { + expect(getAccessRoleFromArchiveMembershipRole(undefined)).toBeUndefined(); + }); + + it('should return undefined for an inherited object property name', () => { + expect( + getAccessRoleFromArchiveMembershipRole( + 'toString' as ArchiveMembershipRoleType, + ), + ).toBeUndefined(); + }); +}); diff --git a/src/app/models/access-role.ts b/src/app/models/access-role.ts index 4f06f63b0..04c00a45f 100644 --- a/src/app/models/access-role.ts +++ b/src/app/models/access-role.ts @@ -22,6 +22,46 @@ export type PermissionsLevel = | 'owner' | 'viewer'; +/** Mirrors Stela's ArchiveMembershipRole enum */ +export type ArchiveMembershipRoleType = + | 'contributor' + | 'curator' + | 'editor' + | 'manager' + | 'owner' + | 'viewer'; + +export const ARCHIVE_MEMBERSHIP_ROLE_TO_ACCESS_ROLE: Record< + ArchiveMembershipRoleType, + AccessRoleType +> = { + contributor: 'access.role.contributor', + curator: 'access.role.curator', + editor: 'access.role.editor', + manager: 'access.role.manager', + owner: 'access.role.owner', + viewer: 'access.role.viewer', +}; + +function isArchiveMembershipRole( + value: unknown, +): value is ArchiveMembershipRoleType { + return ( + typeof value === 'string' && + Object.hasOwn(ARCHIVE_MEMBERSHIP_ROLE_TO_ACCESS_ROLE, value) + ); +} + +export function getAccessRoleFromArchiveMembershipRole( + archiveMembershipRole: ArchiveMembershipRoleType | undefined, +): AccessRoleType | undefined { + if (!isArchiveMembershipRole(archiveMembershipRole)) { + return undefined; + } + + return ARCHIVE_MEMBERSHIP_ROLE_TO_ACCESS_ROLE[archiveMembershipRole]; +} + // Mapping for share link permissions. Note the stela share link API // mistakenly returns "manager" where it should use "curator" -- see // https://github.com/PermanentOrg/stela/issues/540 diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index 436163dc4..872140044 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -30,6 +30,7 @@ const mockStelaFolder = { downloadName: 'test-folder', imageRatio: 1.5, paths: { names: ['path1', 'path2'] }, + accessRole: 'owner', publicAt: null, sort: 'name', thumbnailUrls: { @@ -395,4 +396,65 @@ describe('Folder repo', () => { }); }); }); + + describe('access role translation', () => { + const convertFolder = async (overrides: Record) => { + httpV2Spy.get.and.returnValue( + of([{ items: [{ ...mockStelaFolder, ...overrides }] }]), + ); + const result = await folderRepo.getStelaFolderVOs([ + new FolderVO({ folderId: 123 }), + ]); + return result.getFolderVOs()[0]; + }; + + it("should translate Stela's role into ours", async () => { + const folder = await convertFolder({ accessRole: 'owner' }); + + expect(folder.accessRole).toBe('access.role.owner'); + }); + + it('should translate manager to manager, not curator', async () => { + const folder = await convertFolder({ accessRole: 'manager' }); + + expect(folder.accessRole).toBe('access.role.manager'); + }); + + it('should leave the role undefined when Stela sends nothing', async () => { + const folder = await convertFolder({ accessRole: undefined }); + + expect(folder.accessRole).toBeUndefined(); + }); + + it('should merge onto an existing folder without breaking its role', async () => { + const existingFolder = new FolderVO({ + folderId: '123', + accessRole: 'access.role.owner', + }); + + existingFolder.update(await convertFolder({ accessRole: 'owner' })); + + expect(existingFolder.accessRole).toBe('access.role.owner'); + }); + + it('should translate the role on child folders too', async () => { + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([ + { + items: [ + { ...mockStelaFolder, folderId: '999', accessRole: 'viewer' }, + ], + }, + ]), + ); + + const result = await folderRepo.getWithChildren([ + new FolderVO({ folderId: 123 }), + ]); + const child = result.getFolderVO(true).ChildItemVOs[0]; + + expect(child.accessRole).toBe('access.role.viewer'); + }); + }); }); diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 188d18012..fee99b0a5 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -2,6 +2,10 @@ import { FolderVO, FolderVOData, ItemVO } from '@root/app/models'; import { BaseResponse, BaseRepo } from '@shared/services/api/base'; import { firstValueFrom, Observable } from 'rxjs'; import { DataStatus } from '@models/data-status.enum'; +import { + getAccessRoleFromArchiveMembershipRole, + type ArchiveMembershipRoleType, +} from '@models/access-role'; import { ShareLink } from '@root/app/share-links/models/share-link'; import { convertStelaLocationToLocnVOData, @@ -67,6 +71,7 @@ interface StelaFolder { paths: { names: string[]; }; + accessRole: ArchiveMembershipRoleType; publicAt: string; sort: string; thumbnailUrls?: { @@ -117,6 +122,7 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { view: stelaFolder.view, imageRatio: stelaFolder.imageRatio, type: stelaFolder.type, + accessRole: getAccessRoleFromArchiveMembershipRole(stelaFolder.accessRole), thumbStatus: stelaFolder.status, thumbURL200: stelaFolder.thumbnailUrls?.['200'], thumbURL500: stelaFolder.thumbnailUrls?.['500'], diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index c0403ec8e..4cacd2974 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -466,5 +466,47 @@ describe('RecordRepo', () => { expect(record.thumbURL2000).toBe('https://example.com/2000'); expect(record.thumbnail256).toBe('https://example.com/256'); }); + + it("should translate Stela's role into ours", () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessRole: 'viewer', + } as any); + + expect(record.accessRole).toBe('access.role.viewer'); + }); + + it('should translate manager to manager, not curator', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessRole: 'manager', + } as any); + + expect(record.accessRole).toBe('access.role.manager'); + }); + + it('should leave the role undefined when Stela sends nothing', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + } as any); + + expect(record.accessRole).toBeUndefined(); + }); + + it('should merge onto an existing record without breaking its role', () => { + const existingRecord = new RecordVO({ + recordId: 42, + accessRole: 'access.role.curator', + }); + + existingRecord.update( + convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessRole: 'owner', + } as any), + ); + + expect(existingRecord.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 b49354431..581eac12e 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -18,7 +18,11 @@ import { ThumbnailCache } from '@shared/utilities/thumbnail-cache/thumbnail-cach import { firstValueFrom } from 'rxjs'; import { FileFormat, PermanentFile } from '@models/file-vo'; import { ShareStatus } from '@models/share-vo'; -import { AccessRoleType } from '@models/access-role'; +import { + AccessRoleType, + getAccessRoleFromArchiveMembershipRole, + type ArchiveMembershipRoleType, +} from '@models/access-role'; import { ShareLink } from '@root/app/share-links/models/share-link'; import { getFirst } from '../http-v2/http-v2.service'; import { CENTRAL_TIMEZONE_VO } from './folder.repo'; @@ -106,8 +110,9 @@ export interface StelaShare { thumbURL200: string; }; } -export type StelaRecord = Omit & { +export type StelaRecord = Omit & { tags: Array | null; + accessRole: ArchiveMembershipRoleType; archiveNumber: string; displayDate: string; displayTime?: string; @@ -214,6 +219,7 @@ export const convertStelaRecordToRecordVO = ( convertStelaTagToTagVO(stelaTag, stelaRecord.archiveId), ), archiveNbr: stelaRecord.archiveNumber, + accessRole: getAccessRoleFromArchiveMembershipRole(stelaRecord.accessRole), displayDT: stelaRecord.displayDate, displayTime: stelaRecord.displayTime, folder_linkId: Number.parseInt(stelaRecord.folderLinkId, 10),