diff --git a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts index 6cb497816..42e09b7d9 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts @@ -169,11 +169,14 @@ describe('FileDetailComponent', () => { setup(); (store.dispatch as Mock).mockClear(); const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; component.downloadRevision('3'); expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.fileMetadata$); - expect(openSpy).toHaveBeenCalledWith('https://osf.test/download/?revision=3'); + expect(openSpy).toHaveBeenCalledWith( + `https://osf.test/download/?revision=3&source=file-detail&tz=${encodeURIComponent(timeZone)}` + ); expect(store.dispatch).toHaveBeenCalledWith(new GetFileRevisions('https://osf.test/upload')); }); @@ -223,11 +226,14 @@ describe('FileDetailComponent', () => { setup(); (store.dispatch as Mock).mockClear(); const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; component.downloadFile(); expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.fileMetadata$); - expect(openSpy).toHaveBeenCalledWith('https://osf.test/download'); + expect(openSpy).toHaveBeenCalledWith( + `https://osf.test/download?source=file-detail&tz=${encodeURIComponent(timeZone)}` + ); openSpy.mockRestore(); }); diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index 4e1591d22..5030355ae 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -40,6 +40,7 @@ import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/meta import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { appendDownloadTrackingParams } from '@osf/shared/helpers/download-link.helper'; import { getMfrUrlWithVersion } from '@osf/shared/helpers/mfr-url.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; @@ -250,7 +251,8 @@ export class FileDetailComponent implements OnDestroy { const storageLink = this.file()?.links.upload || ''; if (downloadUrl) { - window.open(`${downloadUrl}/?revision=${version}`)?.focus(); + const link = appendDownloadTrackingParams(`${downloadUrl}/?revision=${version}`, 'file-detail'); + window.open(link)?.focus(); this.actions.getFileRevisions(storageLink); } } @@ -266,7 +268,7 @@ export class FileDetailComponent implements OnDestroy { .logIdentifiableDownload(this.fileMetadata$) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); - window.open(link)?.focus(); + window.open(appendDownloadTrackingParams(link, 'file-detail'))?.focus(); } deleteEntry(link: string): void { diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts index 13f353715..011056ab2 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts @@ -85,7 +85,7 @@ describe('ShareAndDownloadComponent', () => { component.download(); - expect(socialShareService.createDownloadUrl).toHaveBeenCalledWith(mockPreprint.id); + expect(socialShareService.createDownloadUrl).toHaveBeenCalledWith(mockPreprint.id, 'preprint'); expect(openSpy).toHaveBeenCalledWith('https://example.com/download'); expect(focus).toHaveBeenCalled(); expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$); @@ -124,7 +124,7 @@ describe('ShareAndDownloadComponent', () => { component.download(); - expect(socialShareService.createDownloadUrl).toHaveBeenCalledWith(mockPreprint.id); + expect(socialShareService.createDownloadUrl).toHaveBeenCalledWith(mockPreprint.id, 'preprint'); expect(dataciteService.logIdentifiableDownload).not.toHaveBeenCalled(); openSpy.mockRestore(); }); diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts index 896c48f40..bc504b085 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts @@ -43,7 +43,7 @@ export class ShareAndDownloadComponent { return; } - const downloadLink = this.socialShareService.createDownloadUrl(preprint.id); + const downloadLink = this.socialShareService.createDownloadUrl(preprint.id, 'preprint'); const downloadWindow = window.open(downloadLink); if (!downloadWindow) { diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts index 8bbf62371..a1f9454cd 100644 --- a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts @@ -58,7 +58,7 @@ describe('PreprintDownloadRedirectComponent', () => { it('should redirect to download URL when id is present in browser', () => { const redirectSpy = vi.spyOn(PreprintDownloadRedirectComponent.prototype, 'redirect').mockImplementation(vi.fn()); const { mockSocialShareService } = setup({ id: MOCK_ID }); - expect(mockSocialShareService.createDownloadUrl).toHaveBeenCalledWith(MOCK_ID); + expect(mockSocialShareService.createDownloadUrl).toHaveBeenCalledWith(MOCK_ID, 'preprint'); expect(redirectSpy).toHaveBeenCalledWith(MOCK_DOWNLOAD_URL); redirectSpy.mockRestore(); }); diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts index 95dae4b89..81d1a9823 100644 --- a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts @@ -24,7 +24,7 @@ export class PreprintDownloadRedirectComponent { return; } - const url = this.socialShareService.createDownloadUrl(id); + const url = this.socialShareService.createDownloadUrl(id, 'preprint'); this.redirect(url); } diff --git a/src/app/shared/helpers/download-link.helper.ts b/src/app/shared/helpers/download-link.helper.ts new file mode 100644 index 000000000..7de4b7176 --- /dev/null +++ b/src/app/shared/helpers/download-link.helper.ts @@ -0,0 +1,5 @@ +export function appendDownloadTrackingParams(link: string, source: string): string { + const separator = link.includes('?') ? '&' : '?'; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + return `${link}${separator}source=${encodeURIComponent(source)}&tz=${encodeURIComponent(tz)}`; +} diff --git a/src/app/shared/services/file-download.service.spec.ts b/src/app/shared/services/file-download.service.spec.ts index afb9e2372..5e6706eb6 100644 --- a/src/app/shared/services/file-download.service.spec.ts +++ b/src/app/shared/services/file-download.service.spec.ts @@ -44,7 +44,7 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/download/'); + expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/download/', 'files'); expect(openSpy).toHaveBeenCalledWith('/folder/download/?zip=', '_blank'); }); @@ -63,6 +63,7 @@ describe('FileDownloadService', () => { it('logs datacite and opens file download link', () => { const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; service.downloadFile({ resourceId: 'node-1', @@ -71,11 +72,12 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(openSpy).toHaveBeenCalledWith('/file/download/', '_blank'); + expect(openSpy).toHaveBeenCalledWith(`/file/download/?source=files&tz=${encodeURIComponent(timeZone)}`, '_blank'); }); it('downloads file from file model', () => { const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const file = FileModelMock.simple({ kind: FileKind.File, links: { ...FileModelMock.simple().links, download: '/file/download/' }, @@ -88,7 +90,7 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(openSpy).toHaveBeenCalledWith('/file/download/', '_blank'); + expect(openSpy).toHaveBeenCalledWith(`/file/download/?source=files&tz=${encodeURIComponent(timeZone)}`, '_blank'); }); it('downloads folder zip from file model', () => { @@ -106,7 +108,7 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/upload/'); + expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/upload/', 'files'); expect(openSpy).toHaveBeenCalledWith('/folder/upload/?zip=', '_blank'); }); }); diff --git a/src/app/shared/services/file-download.service.ts b/src/app/shared/services/file-download.service.ts index 61f377a74..5999f8d79 100644 --- a/src/app/shared/services/file-download.service.ts +++ b/src/app/shared/services/file-download.service.ts @@ -2,12 +2,17 @@ import { isPlatformBrowser } from '@angular/common'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { appendDownloadTrackingParams } from '@osf/shared/helpers/download-link.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { FilesService } from '@osf/shared/services/files.service'; import { FileDownloadContext } from '../models/files/file-download-context.model'; +// Downloads that go through this service all originate from the files UI, so they're +// tagged with the 'files' source for download telemetry. +const DOWNLOAD_SOURCE = 'files'; + @Injectable({ providedIn: 'root', }) @@ -22,7 +27,7 @@ export class FileDownloadService { } this.dataciteService.logFileDownload(resourceId, resourceType).subscribe(); - this.openInNewTab(this.filesService.getFolderDownloadLink(downloadLink)); + this.openInNewTab(this.filesService.getFolderDownloadLink(downloadLink, DOWNLOAD_SOURCE)); } downloadFile({ resourceId, resourceType, downloadLink }: FileDownloadContext): void { @@ -31,7 +36,7 @@ export class FileDownloadService { } this.dataciteService.logFileDownload(resourceId, resourceType).subscribe(); - this.openInNewTab(downloadLink); + this.openInNewTab(appendDownloadTrackingParams(downloadLink, DOWNLOAD_SOURCE)); } downloadFileOrFolder(params: { resourceId: string; resourceType: string; file: FileModel }): void { @@ -44,12 +49,12 @@ export class FileDownloadService { this.dataciteService.logFileDownload(resourceId, resourceType).subscribe(); if (file.kind === FileKind.File) { - this.openInNewTab(file.links.download); + this.openInNewTab(appendDownloadTrackingParams(file.links.download, DOWNLOAD_SOURCE)); return; } if (file.links.upload) { - this.openInNewTab(this.filesService.getFolderDownloadLink(file.links.upload)); + this.openInNewTab(this.filesService.getFolderDownloadLink(file.links.upload, DOWNLOAD_SOURCE)); } } diff --git a/src/app/shared/services/files.service.spec.ts b/src/app/shared/services/files.service.spec.ts index 6f8a355ab..5168591cb 100644 --- a/src/app/shared/services/files.service.spec.ts +++ b/src/app/shared/services/files.service.spec.ts @@ -118,8 +118,11 @@ describe('FilesService', () => { it('should build folder download link with correct separator', () => { setup(); - expect(service.getFolderDownloadLink('/files/1')).toBe('/files/1?zip='); - expect(service.getFolderDownloadLink('/files/1?foo=bar')).toBe('/files/1?foo=bar&zip='); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(service.getFolderDownloadLink('/files/1')).toBe(`/files/1?zip=&source=&tz=${encodeURIComponent(timeZone)}`); + expect(service.getFolderDownloadLink('/files/1?foo=bar')).toBe( + `/files/1?foo=bar&zip=&source=&tz=${encodeURIComponent(timeZone)}` + ); }); it('should return empty reference when addons api response has no data', async () => { diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 4958da928..2aa60a2e1 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -21,6 +21,7 @@ import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { FileKind } from '../enums/file-kind.enum'; import { ResourceType } from '../enums/resource-type.enum'; +import { appendDownloadTrackingParams } from '../helpers/download-link.helper'; import { AddonMapper } from '../mappers/addon.mapper'; import { ContributorsMapper } from '../mappers/contributors'; import { FilesMapper } from '../mappers/files/files.mapper'; @@ -173,9 +174,9 @@ export class FilesService { return this.jsonApiService.post(link, body); } - getFolderDownloadLink(link: string): string { + getFolderDownloadLink(link: string, source = ''): string { const separator = link.includes('?') ? '&' : '?'; - return `${link}${separator}zip=`; + return appendDownloadTrackingParams(`${link}${separator}zip=`, source); } getFileTarget(fileGuid: string): Observable { diff --git a/src/app/shared/services/social-share.service.spec.ts b/src/app/shared/services/social-share.service.spec.ts index 04060b9f7..5e1c51ec6 100644 --- a/src/app/shared/services/social-share.service.spec.ts +++ b/src/app/shared/services/social-share.service.spec.ts @@ -79,9 +79,13 @@ describe('SocialShareService', () => { it('should create web urls', () => { setup(); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(service.createPreprintUrl('pp-1', 'osf')).toBe('https://osf.test/preprints/osf/pp-1'); expect(service.createGuidUrl('abc12')).toBe('https://osf.test/abc12'); - expect(service.createDownloadUrl('res-1')).toBe('https://osf.test/download/res-1'); + expect(service.createDownloadUrl('res-1')).toBe( + `https://osf.test/download/res-1?source=&tz=${encodeURIComponent(timeZone)}` + ); }); it('should generate social action items from platform config', () => { diff --git a/src/app/shared/services/social-share.service.ts b/src/app/shared/services/social-share.service.ts index d8c9832f2..faa5db904 100644 --- a/src/app/shared/services/social-share.service.ts +++ b/src/app/shared/services/social-share.service.ts @@ -4,6 +4,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { SOCIAL_PLATFORMS } from '../constants/social-platforms.const'; import { SOCIAL_SHARE_URLS } from '../constants/social-share.config'; +import { appendDownloadTrackingParams } from '../helpers/download-link.helper'; import { SocialShareContentModel } from '../models/socials/social-share-content.model'; import { SocialShareLinksModel } from '../models/socials/social-share-links.model'; import { SocialsShareActionItem } from '../models/socials/socials-share-action-item.model'; @@ -56,8 +57,8 @@ export class SocialShareService { return `${this.webUrl}/${guid}`; } - createDownloadUrl(resourceId: string): string { - return `${this.webUrl}/download/${resourceId}`; + createDownloadUrl(resourceId: string, source = ''): string { + return appendDownloadTrackingParams(`${this.webUrl}/download/${resourceId}`, source); } generateSocialActionItems(content: SocialShareContentModel): SocialsShareActionItem[] {