diff --git a/src/app/core/services/edit/edit.service.spec.ts b/src/app/core/services/edit/edit.service.spec.ts index cfde96814..3ae6a3d7f 100644 --- a/src/app/core/services/edit/edit.service.spec.ts +++ b/src/app/core/services/edit/edit.service.spec.ts @@ -148,7 +148,10 @@ describe('EditService', () => { await service.updateItems([record], ['displayTime']); - expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record); + expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record, [ + 'displayTime', + ]); + expect(apiService.record.update).not.toHaveBeenCalled(); expect(apiService.record.get).toHaveBeenCalledWith([record]); }); @@ -202,7 +205,10 @@ describe('EditService', () => { await service.updateItems([record], ['displayTime', 'displayName']); - expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record); + expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record, [ + 'displayTime', + ]); + expect(apiService.record.update).toHaveBeenCalled(); expect(apiService.record.get).toHaveBeenCalledWith([record]); }); @@ -358,7 +364,10 @@ describe('EditService', () => { await service.updateItems(mockFolders, ['displayTime']); - expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder); + expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder, [ + 'displayTime', + ]); + expect(apiService.folder.update).not.toHaveBeenCalled(); expect(apiService.folder.getStelaFolderVOs).toHaveBeenCalledWith( mockFolders, @@ -425,7 +434,10 @@ describe('EditService', () => { await service.updateItems(mockFolders, ['displayTime', 'displayName']); - expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder); + expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder, [ + 'displayTime', + ]); + expect(apiService.folder.update).toHaveBeenCalledWith(mockFolders, [ 'displayName', ]); diff --git a/src/app/core/services/edit/edit.service.ts b/src/app/core/services/edit/edit.service.ts index 229e72f8b..a0065f0ce 100644 --- a/src/app/core/services/edit/edit.service.ts +++ b/src/app/core/services/edit/edit.service.ts @@ -688,16 +688,29 @@ export class EditService { const promises: Array> = recordKey?.length - ? recordKey.map(async (key) => - key === 'displayTime' - ? await Promise.all( - records.map( - async (record) => - await this.api.record.updateStelaRecord(record), - ), - ) - : await this.api.record.update(records, archiveId), - ) + ? recordKey.map(async (key) => { + if (key === 'displayTime') { + return await Promise.all( + records.map( + async (record) => + await this.api.record.updateStelaRecord(record, [ + 'displayTime', + ]), + ), + ); + } + if (key === 'LocnVO') { + return await Promise.all( + records.map( + async (record) => + await this.api.record.updateStelaRecord(record, [ + 'location', + ]), + ), + ); + } + return await this.api.record.update(records, archiveId); + }) : [this.api.record.update(records, archiveId)]; await Promise.all(promises); @@ -714,16 +727,29 @@ export class EditService { const promises: Array> = folderKeys?.length - ? folderKeys.map(async (key) => - key === 'displayTime' - ? await Promise.all( - folders.map( - async (folder) => - await this.api.folder.updateStelaFolder(folder), - ), - ) - : await this.api.folder.update(folders, [key]), - ) + ? folderKeys.map(async (key) => { + if (key === 'displayTime') { + return await Promise.all( + folders.map( + async (folder) => + await this.api.folder.updateStelaFolder(folder, [ + 'displayTime', + ]), + ), + ); + } + if (key === 'LocnVO') { + return await Promise.all( + folders.map( + async (folder) => + await this.api.folder.updateStelaFolder(folder, [ + 'location', + ]), + ), + ); + } + return await this.api.folder.update(folders, [key]); + }) : [this.api.folder.update(folders)]; await Promise.all(promises); diff --git a/src/app/file-browser/components/location-picker/location-picker.component.spec.ts b/src/app/file-browser/components/location-picker/location-picker.component.spec.ts index 612d35d3a..e3fb9197a 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.spec.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.spec.ts @@ -1,25 +1,162 @@ -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { LocationPickerComponent } from './location-picker.component'; - -// describe('LocationPickerComponent', () => { -// let component: LocationPickerComponent; -// let fixture: ComponentFixture; - -// beforeEach(async(() => { -// TestBed.configureTestingModule({ -// declarations: [ LocationPickerComponent ] -// }) -// .compileComponents(); -// })); - -// beforeEach(() => { -// fixture = TestBed.createComponent(LocationPickerComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { ApiService } from '@shared/services/api/api.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { EditService } from '@core/services/edit/edit.service'; +import { ProfileService } from '@shared/services/profile/profile.service'; +import { PrLocationPipe } from '@shared/pipes/pr-location.pipe'; +import { LocationPickerComponent } from './location-picker.component'; + +const fakeGoogleMaps = { + LatLng: class { + constructor(public input: unknown) {} + }, + places: { + Autocomplete: class { + setFields(): void {} + addListener(): void {} + getPlace(): unknown { + return null; + } + }, + }, +}; + +const buildAddressComponents = ( + overrides: Partial< + Record + > = {}, +): google.maps.GeocoderAddressComponent[] => { + const defaults: Record = { + street_number: { long_name: '55', short_name: '55' }, + route: { long_name: 'Rue Plumet', short_name: 'Rue Plumet' }, + locality: { long_name: 'Paris', short_name: 'Paris' }, + postal_code: { long_name: '75007', short_name: '75007' }, + administrative_area_level_1: { + long_name: 'Ile-de-France', + short_name: 'IDF', + }, + country: { long_name: 'France', short_name: 'FR' }, + }; + const merged = { ...defaults, ...overrides }; + return Object.entries(merged) + .filter(([, value]) => value !== undefined) + .map(([type, value]) => ({ + long_name: value.long_name, + short_name: value.short_name, + types: [type], + })) as google.maps.GeocoderAddressComponent[]; +}; + +const buildPlace = ( + overrides: Partial = {}, + addressOverrides: + | Parameters[0] + | undefined = undefined, +): google.maps.places.PlaceResult => + ({ + name: "Jean Valjean's House", + address_components: buildAddressComponents(addressOverrides), + geometry: { + location: { + lat: () => 48.83, + lng: () => 2.3, + }, + }, + ...overrides, + }) as unknown as google.maps.places.PlaceResult; + +describe('LocationPickerComponent', () => { + let fixture: ComponentFixture; + let component: LocationPickerComponent; + const testWindow = window as unknown as { google?: unknown }; + let previousGoogle: unknown; + let hadGoogle = false; + + beforeAll(() => { + hadGoogle = 'google' in testWindow; + previousGoogle = testWindow.google; + testWindow.google = { maps: fakeGoogleMaps }; + }); + + afterAll(() => { + if (hadGoogle) { + testWindow.google = previousGoogle; + } else { + delete testWindow.google; + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LocationPickerComponent], + providers: [ + { provide: ApiService, useValue: {} }, + { provide: MessageService, useValue: {} }, + { provide: EditService, useValue: {} }, + { provide: ProfileService, useValue: {} }, + { provide: PrLocationPipe, useValue: { transform: () => ({}) } }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(LocationPickerComponent); + component = fixture.componentInstance; + }); + + describe('createLocnFromPlace', () => { + it('populates the spec-aligned fields', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.sublocation).toBe('55 Rue Plumet'); + expect(locn.city).toBe('Paris'); + expect(locn.adminOneName).toBe('Ile-de-France'); + expect(locn.country).toBe('France'); + expect(locn.postalCode).toBe('75007'); + expect(locn.latitude).toBe(48.83); + expect(locn.longitude).toBe(2.3); + }); + + it('does not write deprecated legacy fields', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.streetNumber).toBeUndefined(); + expect(locn.streetName).toBeUndefined(); + expect(locn.locality).toBeUndefined(); + expect(locn.countryCode).toBeUndefined(); + expect(locn.adminOneCode).toBeUndefined(); + expect(locn.adminTwoName).toBeUndefined(); + expect(locn.adminTwoCode).toBeUndefined(); + }); + + it('sets sublocation to null when no street number or street name is available', () => { + const locn = component.createLocnFromPlace( + buildPlace({}, { street_number: undefined, route: undefined }), + ); + + expect(locn.sublocation).toBeNull(); + }); + + it('falls back to streetName alone when streetNumber is absent', () => { + const locn = component.createLocnFromPlace( + buildPlace({}, { street_number: undefined }), + ); + + expect(locn.sublocation).toBe('Rue Plumet'); + }); + + it('writes name when the place name does not include the sublocation', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.name).toBe("Jean Valjean's House"); + }); + + it('omits name when the place name already matches the sublocation', () => { + const locn = component.createLocnFromPlace( + buildPlace({ name: '55 Rue Plumet' }), + ); + + expect(locn.name).toBeUndefined(); + }); + }); +}); diff --git a/src/app/file-browser/components/location-picker/location-picker.component.ts b/src/app/file-browser/components/location-picker/location-picker.component.ts index 14cc1b15d..603bd3ffd 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.ts @@ -229,32 +229,32 @@ export class LocationPickerComponent implements OnInit, AfterViewInit { createLocnFromPlace(place: google.maps.places.PlaceResult) { const addr = place.address_components; + const streetNumber = getComponentName(addr, 'street_number'); + const streetName = getComponentName(addr, 'route'); + const sublocation = + [streetNumber, streetName].filter(Boolean).join(' ') || null; const locn: LocnVOData = { latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng(), - streetNumber: getComponentName(addr, 'street_number'), - streetName: getComponentName(addr, 'route'), postalCode: getComponentName(addr, 'postal_code'), - locality: getComponentName(addr, 'locality'), adminOneName: getComponentName(addr, 'administrative_area_level_1'), - adminOneCode: getComponentName(addr, 'administrative_area_level_1', true), - adminTwoName: getComponentName(addr, 'administrative_area_level_2'), - adminTwoCode: getComponentName(addr, 'administrative_area_level_2', true), country: getComponentName(addr, 'country'), - countryCode: getComponentName(addr, 'country', true), + sublocation, + city: getComponentName(addr, 'locality'), }; + if (place.name && (!sublocation || !place.name.includes(sublocation))) { + locn.name = place.name; + } + return locn; function getComponentName( addressComponents: google.maps.GeocoderAddressComponent[], - type, - getShortName = true, + type: string, ) { const component = find(addressComponents, (c) => c.types.includes(type)); - return component - ? component[getShortName ? 'short_name' : 'long_name'] - : null; + return component ? component.long_name : null; } } } diff --git a/src/app/models/locn-vo.ts b/src/app/models/locn-vo.ts index 409b9fa0e..2ebbfb20e 100644 --- a/src/app/models/locn-vo.ts +++ b/src/app/models/locn-vo.ts @@ -1,5 +1,7 @@ import { BaseVOData } from '@models/base-vo'; +export type LocationPrecision = 'approximate' | 'uncertain' | 'unknown'; + export interface LocnVOData extends BaseVOData { locnId?: number; timeZoneId?: number; @@ -25,6 +27,11 @@ export interface LocnVOData extends BaseVOData { geometryAsArray?: string; geoCodeType?: string; geoCodeResponseAsXml?: string; + name?: string; + sublocation?: string; + city?: string; + altitudeMeters?: number; + locationPrecision?: LocationPrecision; status?: string; type?: string; } diff --git a/src/app/shared/pipes/pr-location.pipe.spec.ts b/src/app/shared/pipes/pr-location.pipe.spec.ts new file mode 100644 index 000000000..62235f747 --- /dev/null +++ b/src/app/shared/pipes/pr-location.pipe.spec.ts @@ -0,0 +1,54 @@ +import { LocnVOData } from '@models'; +import { PrLocationPipe } from './pr-location.pipe'; + +describe('PrLocationPipe', () => { + let pipe: PrLocationPipe; + + beforeEach(() => { + pipe = new PrLocationPipe(); + }); + + it('returns null when input is null', () => { + expect(pipe.transform(null)).toBeNull(); + }); + + it('renders the spec-aligned fields', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + city: 'Paris', + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }; + + const output = pipe.transform(locn); + + expect(output.line1).toBe('55 Rue Plumet'); + expect(output.line2).toContain('Paris'); + expect(output.full.startsWith('55 Rue Plumet, ')).toBe(true); + }); + + it('falls through to lat/long when address fields are missing', () => { + const locn: LocnVOData = { + latitude: 48.83, + longitude: 2.3, + country: 'France', + }; + + const output = pipe.transform(locn); + + expect(output.line1).toBe('48.83, 2.3'); + expect(output.line2).toContain('France'); + }); + + it('reports unknown when no address or coordinates fall through to line2', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + }; + + const output = pipe.transform(locn); + + expect(output.line2).toBe('Unknown Location'); + }); +}); diff --git a/src/app/shared/pipes/pr-location.pipe.ts b/src/app/shared/pipes/pr-location.pipe.ts index 27d732043..4327c9d1e 100644 --- a/src/app/shared/pipes/pr-location.pipe.ts +++ b/src/app/shared/pipes/pr-location.pipe.ts @@ -19,16 +19,19 @@ export class PrLocationPipe implements PipeTransform { const output: LocnPipeOutput = {}; + const hasCoordinates = + locnVO.latitude != null && + locnVO.latitude !== '' && + locnVO.longitude != null && + locnVO.longitude !== ''; + // order by priority/usefulness const queue = [ - locnVO.streetNumber - ? locnVO.streetNumber + ' ' + locnVO.streetName - : locnVO.streetName, - locnVO.locality, + locnVO.sublocation, + locnVO.city, locnVO.adminOneName, - locnVO.latitude + ', ' + locnVO.longitude, + hasCoordinates ? `${locnVO.latitude}, ${locnVO.longitude}` : null, locnVO.country, - locnVO.countryCode, ]; const line2 = []; diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index bea2d65f3..6e36847a8 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -286,6 +286,34 @@ describe('Folder repo', () => { expect(result.Results[0][0].FolderVO.folderId).toBe('123'); expect(result.Results[0][0].FolderVO.displayName).toBe('Test Folder'); }); + + it('should send the location (and not displayTime) when updating location', async () => { + const folderVO = new FolderVO({ + folderId: 123, + displayTime: '1985-05-20T00:00:00Z', + LocnVO: { + city: 'Paris', + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + + httpV2Spy.patch.and.returnValue(of([mockStelaFolder])); + + await folderRepo.updateStelaFolder(folderVO, ['location']); + + expect(httpV2Spy.patch).toHaveBeenCalledWith('v2/folder/123', { + location: { + city: 'Paris', + state: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + }); }); describe('getStelaFolderVOs', () => { diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 87ffa4ae3..01718ae7c 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -4,11 +4,13 @@ import { firstValueFrom, Observable } from 'rxjs'; import { DataStatus } from '@models/data-status.enum'; import { ShareLink } from '@root/app/share-links/models/share-link'; import { + convertLocnVODataToStelaLocation, convertStelaLocationToLocnVOData, convertStelaRecordToRecordVO, convertStelaSharetoShareVO, convertStelaTagToTagVO, StelaLocation, + StelaLocationUpdateRequest, StelaShare, StelaTag, type StelaRecord, @@ -234,10 +236,23 @@ export class FolderRepo extends BaseRepo { return folderResponse?.items || []; } - public async updateStelaFolder(folderVO: FolderVO): Promise { - const payload = { - displayTime: folderVO.displayTime, - }; + public async updateStelaFolder( + folderVO: FolderVO, + fields: Array<'displayTime' | 'location'> = ['displayTime'], + ): Promise { + // We patch the folder itself, sending only the fields being updated. + // Locations are written as part of the folder rather than via standalone + // location objects. + const payload: { + displayTime?: string; + location?: StelaLocationUpdateRequest; + } = {}; + if (fields.includes('displayTime')) { + payload.displayTime = folderVO.displayTime; + } + if (fields.includes('location') && folderVO.LocnVO) { + payload.location = convertLocnVODataToStelaLocation(folderVO.LocnVO); + } const response = await firstValueFrom( this.httpV2.patch(`v2/folder/${folderVO.folderId}`, payload), diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index b027956b1..175577dd4 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -6,8 +6,14 @@ import { import { of } from 'rxjs'; import { environment } from '@root/environments/environment'; import { HttpService } from '@shared/services/http/http.service'; -import { RecordRepo, RecordResponse } from '@shared/services/api/record.repo'; -import { RecordVO } from '@root/app/models'; +import { + RecordRepo, + RecordResponse, + StelaLocation, + convertStelaLocationToLocnVOData, + convertLocnVODataToStelaLocation, +} from '@shared/services/api/record.repo'; +import { LocnVOData, RecordVO } from '@root/app/models'; import { provideHttpClient, withInterceptorsFromDi, @@ -222,6 +228,113 @@ describe('RecordRepo', () => { }); }); + describe('convertStelaLocationToLocnVOData', () => { + it('returns null when the stela location has no id', () => { + expect(convertStelaLocationToLocnVOData(null)).toBeNull(); + expect( + convertStelaLocationToLocnVOData({ id: '' } as StelaLocation), + ).toBeNull(); + }); + + it('parses the id and remaps state/precision onto the LocnVO shape', () => { + const stelaLocation: StelaLocation = { + id: '42', + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + state: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + precision: 'approximate', + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.locnId).toBe(42); + expect(result.name).toBe("Jean Valjean's House"); + expect(result.sublocation).toBe('55 Rue Plumet'); + expect(result.city).toBe('Paris'); + expect(result.adminOneName).toBe('Ile-de-France'); + expect(result.postalCode).toBe('75007'); + expect(result.country).toBe('France'); + expect(result.altitudeMeters).toBe(35); + expect(result.locationPrecision).toBe('approximate'); + + expect((result as Record).state).toBeUndefined(); + expect((result as Record).precision).toBeUndefined(); + }); + + it('coerces null state/precision to undefined', () => { + const stelaLocation: StelaLocation = { + id: '7', + state: null, + precision: null, + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.adminOneName).toBeUndefined(); + expect(result.locationPrecision).toBeUndefined(); + }); + }); + + describe('convertLocnVODataToStelaLocation', () => { + it('remaps the LocnVO shape onto the stela location request', () => { + const locn: LocnVOData = { + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + adminOneName: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + locationPrecision: 'approximate', + }; + + const result = convertLocnVODataToStelaLocation(locn); + + expect(result).toEqual({ + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + state: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + precision: 'approximate', + }); + }); + + it('coerces string coordinates to numbers', () => { + const result = convertLocnVODataToStelaLocation({ + latitude: '48.83', + longitude: '2.3', + }); + + expect(result.latitude).toBe(48.83); + expect(result.longitude).toBe(2.3); + }); + + it('omits undefined and empty fields so the location is clean', () => { + const result = convertLocnVODataToStelaLocation({ + city: 'Paris', + latitude: '', + longitude: null, + }); + + expect(result).toEqual({ city: 'Paris' }); + expect('latitude' in result).toBe(false); + expect('longitude' in result).toBe(false); + }); + }); + describe('updateStelaRecord', () => { let httpV2PatchSpy: jasmine.Spy; let httpSendRequestPromiseSpy: jasmine.Spy; @@ -306,5 +419,33 @@ describe('RecordRepo', () => { expect(resultRecord).toBeInstanceOf(RecordVO); }); + + it('should send the location (and not displayTime) when updating location', async () => { + const recordVO = new RecordVO({ + recordId: 42, + displayTime: '1985-05-20T00:00:00.000Z', + LocnVO: { + city: 'Paris', + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + + httpV2PatchSpy.and.returnValue(of([fakeStelaRecord])); + + await repo.updateStelaRecord(recordVO, ['location']); + + expect(httpV2PatchSpy).toHaveBeenCalledWith('v2/records/42', { + location: { + city: 'Paris', + state: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + }); }); }); diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index b39dfb2cd..ca65bbb49 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -5,6 +5,7 @@ import { FolderLinkType, ShareVO, LocnVOData, + LocationPrecision, } from '@root/app/models'; import { BaseResponse, @@ -71,16 +72,16 @@ interface StelaFile { } export interface StelaLocation { id: string; - streetNumber: string; - streetName: string; - locality: string; - county: string; - state: string; - latitude: number; - longitude: number; - country: string; - countryCode: string; - displayName: string | null; + name?: string | null; + sublocation?: string | null; + city?: string | null; + state?: string | null; + postalCode?: string | null; + country?: string | null; + latitude?: number | null; + longitude?: number | null; + altitudeMeters?: number | null; + precision?: LocationPrecision | null; } interface StelaArchive { id: string; @@ -157,14 +158,69 @@ export const convertStelaSharetoShareVO = (stelaShare: StelaShare): ShareVO => }); export const convertStelaLocationToLocnVOData = ( - stelaLocation: StelaLocation, -): LocnVOData => - stelaLocation?.id - ? { - ...stelaLocation, - locnId: Number.parseInt(stelaLocation.id, 10), + stelaLocation: StelaLocation | null | undefined, +): LocnVOData | null => { + if (!stelaLocation?.id) { + return null; + } + const { state, precision, ...rest } = stelaLocation; + return { + ...rest, + locnId: Number.parseInt(stelaLocation.id, 10), + adminOneName: state ?? undefined, + locationPrecision: precision ?? undefined, + }; +}; + +// The location shape accepted by the stela record/folder PATCH and POST +// endpoints. The location is written as part of the record/folder rather than +// as a standalone object, and is sent without an id (stela creates or updates +// the underlying location row). +export interface StelaLocationUpdateRequest { + name?: string; + sublocation?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + latitude?: number; + longitude?: number; + altitudeMeters?: number; + precision?: LocationPrecision; +} + +const toStelaCoordinate = ( + value: string | number | null | undefined, +): number | undefined => + value === null || value === undefined || value === '' + ? undefined + : Number(value); + +export const convertLocnVODataToStelaLocation = ( + locn: LocnVOData, +): StelaLocationUpdateRequest => { + const location: StelaLocationUpdateRequest = { + name: locn.name ?? undefined, + sublocation: locn.sublocation ?? undefined, + city: locn.city ?? undefined, + state: locn.adminOneName ?? undefined, + postalCode: locn.postalCode ?? undefined, + country: locn.country ?? undefined, + latitude: toStelaCoordinate(locn.latitude), + longitude: toStelaCoordinate(locn.longitude), + altitudeMeters: locn.altitudeMeters ?? undefined, + precision: locn.locationPrecision ?? undefined, + }; + // Strip undefined fields so we send a clean, non-empty location object. + (Object.keys(location) as Array).forEach( + (key) => { + if (location[key] === undefined) { + delete location[key]; } - : null; + }, + ); + return location; +}; export const convertStelaRecordToRecordVO = ( stelaRecord: StelaRecord, @@ -474,17 +530,31 @@ export class RecordRepo extends BaseRepo { ); } - public async updateStelaRecord(recordVO: RecordVO): Promise { + public async updateStelaRecord( + recordVO: RecordVO, + fields: Array<'displayTime' | 'location'> = ['displayTime'], + ): Promise { const recordId = recordVO.recordId ?? (await this.getRecordIdByArchiveNbr(recordVO.archiveNbr)); - // For now we only send displayTime. This will evolve until we can - // update the whole record using this method. + // We patch the record itself, sending only the fields being updated. + // Locations are written as part of the record rather than via standalone + // location objects. This will evolve until we can update the whole record + // using this method. + const payload: { + displayTime?: string; + location?: StelaLocationUpdateRequest; + } = {}; + if (fields.includes('displayTime')) { + payload.displayTime = recordVO.displayTime; + } + if (fields.includes('location') && recordVO.LocnVO) { + payload.location = convertLocnVODataToStelaLocation(recordVO.LocnVO); + } + const stelaRecord = await firstValueFrom( - this.httpV2.patch(`v2/records/${recordId}`, { - displayTime: recordVO.displayTime, - }), + this.httpV2.patch(`v2/records/${recordId}`, payload), ); const simulatedV1RecordResponseResults = stelaRecord.map((record) => ({