Skip to content

Commit aa74a5f

Browse files
dwhoganmauberti-bc
andauthored
SIMSBIOHUB-833: Bulk Download Based on Query (#355)
* feat(database): add search_filters column to download table * feat(download): path, service, repo adds * feat(download): Frontend — Download All button * fix(download): Anonymous user * refactor(download): cleanup interfaces * fix(download): Lint * fix(download): small fix * fix(download): addressed reviewer's comments * fix(download): fixed a few tests --------- Co-authored-by: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com>
1 parent a0fb3dc commit aa74a5f

24 files changed

+1020
-93
lines changed

api/src/__integration__/db/download-service.integration.ts

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ describe('DownloadPipelineService (integration)', function () {
6060
const featureId1 = await createTestFeature(connection, submissionId, 'dataset', { name: 'Dataset A' });
6161
const featureId2 = await createTestFeature(connection, submissionId, 'dataset', { name: 'Dataset B' });
6262
// Step 2: Create download request through the service (no team for test)
63-
const result = await service.createDownloadRequest(null, [featureId1, featureId2]);
63+
const result = await service.createDownloadRequest({
64+
systemUserId: null,
65+
teamId: null,
66+
submissionFeatureIds: [featureId1, featureId2]
67+
});
6468

6569
// Step 3: Verify download record was created with correct initial state
6670
const download = await crudService.findDownloadById(result.download_id);
@@ -94,7 +98,7 @@ describe('DownloadPipelineService (integration)', function () {
9498

9599
// Step 3: Attempt to link a non-existent submission_feature_id
96100
try {
97-
await service.createDownloadRequest(null, [999999]);
101+
await service.createDownloadRequest({ systemUserId: null, teamId: null, submissionFeatureIds: [999999] });
98102
expect.fail('Should have thrown a foreign key violation');
99103
} catch (error) {
100104
// Expected: FK constraint violation on download_feature.submission_feature_id
@@ -116,7 +120,11 @@ describe('DownloadPipelineService (integration)', function () {
116120
// Step 1: Create a download
117121
const submissionId = await createTestSubmission(connection);
118122
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Test' });
119-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
123+
const { download_id } = await service.createDownloadRequest({
124+
systemUserId: null,
125+
teamId: null,
126+
submissionFeatureIds: [featureId]
127+
});
120128

121129
// Step 2: Transition to processing
122130
await service.updateDownloadStatus(download_id, DownloadStatusEnum.PROCESSING);
@@ -143,6 +151,7 @@ describe('DownloadPipelineService (integration)', function () {
143151
describe('full status lifecycle', () => {
144152
it('should transition pending → processing → ready and track all timestamps', async () => {
145153
// Step 1: Create a download with features
154+
const apiUserId = connection.systemUserId();
146155
const submissionId = await createTestSubmission(connection);
147156
const featureId1 = await createTestFeature(connection, submissionId, 'species_observation', {
148157
taxon_id: 180703,
@@ -154,7 +163,11 @@ describe('DownloadPipelineService (integration)', function () {
154163
count: 12,
155164
timestamp: '2024-01-16T14:30:00Z'
156165
});
157-
const { download_id } = await service.createDownloadRequest(null, [featureId1, featureId2]);
166+
const { download_id } = await service.createDownloadRequest({
167+
systemUserId: apiUserId,
168+
teamId: null,
169+
submissionFeatureIds: [featureId1, featureId2]
170+
});
158171

159172
// Step 2: Verify initial state
160173
const initial = await crudService.findDownloadById(download_id);
@@ -191,7 +204,11 @@ describe('DownloadPipelineService (integration)', function () {
191204
const submissionId = await createTestSubmission(connection);
192205
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Size Test' });
193206

194-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
207+
const { download_id } = await service.createDownloadRequest({
208+
systemUserId: null,
209+
teamId: null,
210+
submissionFeatureIds: [featureId]
211+
});
195212

196213
const sizeData = await crudService.getDownloadFeatureSummaries(download_id, null);
197214

@@ -209,7 +226,11 @@ describe('DownloadPipelineService (integration)', function () {
209226
const securedFeatureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Secured' });
210227
await secureFeature(securedFeatureId);
211228

212-
const { download_id } = await service.createDownloadRequest(null, [openFeatureId, securedFeatureId]);
229+
const { download_id } = await service.createDownloadRequest({
230+
systemUserId: null,
231+
teamId: null,
232+
submissionFeatureIds: [openFeatureId, securedFeatureId]
233+
});
213234

214235
const sizeData = await crudService.getDownloadFeatureSummaries(download_id, null);
215236

@@ -235,7 +256,11 @@ describe('DownloadPipelineService (integration)', function () {
235256
);
236257

237258
// Step 2: Create download and plan fragments
238-
const { download_id } = await service.createDownloadRequest(null, [childFeatureId]);
259+
const { download_id } = await service.createDownloadRequest({
260+
systemUserId: null,
261+
teamId: null,
262+
submissionFeatureIds: [childFeatureId]
263+
});
239264
const sizeEstimate = await service.estimateDownloadSize(download_id, null);
240265
await service.planFragments(download_id, sizeEstimate);
241266

@@ -275,7 +300,11 @@ describe('DownloadPipelineService (integration)', function () {
275300
});
276301

277302
// Step 2: Create download and plan fragments
278-
const { download_id } = await service.createDownloadRequest(null, [rootFeatureId]);
303+
const { download_id } = await service.createDownloadRequest({
304+
systemUserId: null,
305+
teamId: null,
306+
submissionFeatureIds: [rootFeatureId]
307+
});
279308
const sizeEstimate = await service.estimateDownloadSize(download_id, null);
280309
await service.planFragments(download_id, sizeEstimate);
281310

@@ -386,7 +415,11 @@ describe('DownloadPipelineService (integration)', function () {
386415
async function createAnonymousDownload(): Promise<string> {
387416
const submissionId = await createTestSubmission(connection);
388417
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Anon' });
389-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
418+
const { download_id } = await service.createDownloadRequest({
419+
systemUserId: null,
420+
teamId: null,
421+
submissionFeatureIds: [featureId]
422+
});
390423
// createDownloadRequest sets system_user_id from connection.systemUserId(),
391424
// but the API user connection returns a real user id. Clear it for anonymous:
392425
await connection.sql(SQL`
@@ -438,7 +471,12 @@ describe('DownloadPipelineService (integration)', function () {
438471
// Create a download with team_id set
439472
const submissionId = await createTestSubmission(connection);
440473
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Secured' });
441-
const { download_id } = await service.createDownloadRequest(teamId, [featureId], dataRequestId);
474+
const { download_id } = await service.createDownloadRequest({
475+
systemUserId: null,
476+
teamId,
477+
submissionFeatureIds: [featureId],
478+
dataRequestId
479+
});
442480

443481
// Clear system_user_id but keep team_id
444482
await connection.sql(SQL`
@@ -459,20 +497,27 @@ describe('DownloadPipelineService (integration)', function () {
459497

460498
describe('getAuthorizedDownload', () => {
461499
it('should authorize owner (system_user_id path)', async () => {
500+
const apiUserId = connection.systemUserId();
462501
const submissionId = await createTestSubmission(connection);
463502
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Owned' });
464-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
465-
466-
// API user is the owner (createDownloadRequest sets system_user_id)
467-
const apiUserId = connection.systemUserId();
503+
const { download_id } = await service.createDownloadRequest({
504+
systemUserId: apiUserId,
505+
teamId: null,
506+
submissionFeatureIds: [featureId]
507+
});
468508
await service.getAuthorizedDownload(download_id, apiUserId);
469509
// No error thrown — authorized
470510
});
471511

472512
it('should throw HTTP403 for wrong user (not owner, not shared, not team member)', async () => {
513+
const apiUserId = connection.systemUserId();
473514
const submissionId = await createTestSubmission(connection);
474515
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Owned' });
475-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
516+
const { download_id } = await service.createDownloadRequest({
517+
systemUserId: apiUserId,
518+
teamId: null,
519+
submissionFeatureIds: [featureId]
520+
});
476521

477522
const otherUserId = await createOtherUser();
478523
try {
@@ -486,7 +531,11 @@ describe('DownloadPipelineService (integration)', function () {
486531
it('should authorize via download_share (shared path)', async () => {
487532
const submissionId = await createTestSubmission(connection);
488533
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Shared' });
489-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
534+
const { download_id } = await service.createDownloadRequest({
535+
systemUserId: null,
536+
teamId: null,
537+
submissionFeatureIds: [featureId]
538+
});
490539

491540
// Share with another user
492541
const otherUserId = await createOtherUser();
@@ -505,7 +554,12 @@ describe('DownloadPipelineService (integration)', function () {
505554
// Create download linked to data request
506555
const submissionId = await createTestSubmission(connection);
507556
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Request' });
508-
const { download_id } = await service.createDownloadRequest(teamId, [featureId], dataRequestId);
557+
const { download_id } = await service.createDownloadRequest({
558+
systemUserId: null,
559+
teamId,
560+
submissionFeatureIds: [featureId],
561+
dataRequestId
562+
});
509563

510564
await service.getAuthorizedDownload(download_id, otherUserId);
511565
// No error thrown — authorized
@@ -520,7 +574,12 @@ describe('DownloadPipelineService (integration)', function () {
520574

521575
const submissionId = await createTestSubmission(connection);
522576
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Locked' });
523-
const { download_id } = await service.createDownloadRequest(teamId, [featureId], dataRequestId);
577+
const { download_id } = await service.createDownloadRequest({
578+
systemUserId: null,
579+
teamId,
580+
submissionFeatureIds: [featureId],
581+
dataRequestId
582+
});
524583

525584
try {
526585
await service.getAuthorizedDownload(download_id, outsider);
@@ -535,11 +594,14 @@ describe('DownloadPipelineService (integration)', function () {
535594

536595
describe('getDownloadsByTeamMembership', () => {
537596
it('should return owned downloads', async () => {
597+
const apiUserId = connection.systemUserId();
538598
const submissionId = await createTestSubmission(connection);
539599
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Mine' });
540-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
541-
542-
const apiUserId = connection.systemUserId();
600+
const { download_id } = await service.createDownloadRequest({
601+
systemUserId: apiUserId,
602+
teamId: null,
603+
submissionFeatureIds: [featureId]
604+
});
543605
const downloads = await crudService.getDownloadsByTeamMembership(apiUserId);
544606
const ids = downloads.map((d) => d.download_id);
545607
expect(ids).to.include(download_id);
@@ -548,7 +610,11 @@ describe('DownloadPipelineService (integration)', function () {
548610
it('should return shared downloads', async () => {
549611
const submissionId = await createTestSubmission(connection);
550612
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Shared List' });
551-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
613+
const { download_id } = await service.createDownloadRequest({
614+
systemUserId: null,
615+
teamId: null,
616+
submissionFeatureIds: [featureId]
617+
});
552618

553619
const otherUserId = await createOtherUser();
554620
await shareDownload(download_id, otherUserId);
@@ -566,7 +632,12 @@ describe('DownloadPipelineService (integration)', function () {
566632

567633
const submissionId = await createTestSubmission(connection);
568634
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Team DL' });
569-
const { download_id } = await service.createDownloadRequest(teamId, [featureId], dataRequestId);
635+
const { download_id } = await service.createDownloadRequest({
636+
systemUserId: null,
637+
teamId,
638+
submissionFeatureIds: [featureId],
639+
dataRequestId
640+
});
570641

571642
const downloads = await crudService.getDownloadsByTeamMembership(otherUserId);
572643
const ids = downloads.map((d) => d.download_id);
@@ -576,7 +647,11 @@ describe('DownloadPipelineService (integration)', function () {
576647
it('should not return downloads the user has no access to', async () => {
577648
const submissionId = await createTestSubmission(connection);
578649
const featureId = await createTestFeature(connection, submissionId, 'dataset', { name: 'Private' });
579-
const { download_id } = await service.createDownloadRequest(null, [featureId]);
650+
const { download_id } = await service.createDownloadRequest({
651+
systemUserId: null,
652+
teamId: null,
653+
submissionFeatureIds: [featureId]
654+
});
580655

581656
const otherUserId = await createOtherUser();
582657
const downloads = await crudService.getDownloadsByTeamMembership(otherUserId);

api/src/__integration__/system/download-system.integration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,11 @@ describe('DownloadPipelineService download pipeline (system)', function () {
544544
* Helper: run the full download pipeline and return the resulting zip.
545545
*/
546546
async function executeAndGetZip(featureIds: number[]): Promise<{ zip: AdmZip; downloadId: string }> {
547-
const { download_id } = await service.createDownloadRequest(null, featureIds);
547+
const { download_id } = await service.createDownloadRequest({
548+
systemUserId: null,
549+
teamId: null,
550+
submissionFeatureIds: featureIds
551+
});
548552

549553
// Run the three-phase pipeline within the test transaction
550554
await service.planDownloadIfNeeded(download_id);

api/src/models/download.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { SearchFeatureFiltersSchema } from '../services/search-feature-service.interface';
23
import { DownloadStatusZod } from './download-status';
34

45
export const DownloadRecord = z.object({
@@ -53,3 +54,35 @@ export interface DownloadSizeEstimate {
5354
/** The features included in this download with per-feature estimated_byte_size. */
5455
features: DownloadFeatureSummary[];
5556
}
57+
58+
/**
59+
* Payload for creating a new download record.
60+
*
61+
* Bundles the identifiers and optional configuration that flow from the
62+
* pipeline service through to the repository INSERT.
63+
*/
64+
export const CreateDownload = z.object({
65+
teamId: z.string().nullable(),
66+
dataRequestId: z.string().nullable(),
67+
fragmentSizeBytes: z.number().optional(),
68+
systemUserId: z.number().nullable().optional(),
69+
filters: SearchFeatureFiltersSchema.optional()
70+
});
71+
export type CreateDownload = z.infer<typeof CreateDownload>;
72+
73+
/**
74+
* Payload for creating a download request through the pipeline.
75+
*
76+
* Accepted by the pipeline service's public entry point. Includes the feature
77+
* selection and optional fragment sizing before the request is persisted and
78+
* features are linked.
79+
*/
80+
export const CreateDownloadRequest = z.object({
81+
systemUserId: z.number().nullable(),
82+
teamId: z.string().nullable(),
83+
submissionFeatureIds: z.array(z.number()),
84+
dataRequestId: z.string().optional(),
85+
fragmentSizeMb: z.number().optional(),
86+
filters: SearchFeatureFiltersSchema.optional()
87+
});
88+
export type CreateDownloadRequest = z.infer<typeof CreateDownloadRequest>;

0 commit comments

Comments
 (0)