From d43fd04e56537aeae52432d9cdc2c1fa40e5576d Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 5 Jun 2026 13:54:18 -0400 Subject: [PATCH 1/6] fix(cli): only send Content-Type on requests with a body and increase timeout DELETE requests were incorrectly including Content-Type: application/json with no body, causing production servers to reject them with 400. The request timeout was also too short (2s) for force-delete operations that cascade through instrument records and sessions. --- cli/odc-cli | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/odc-cli b/cli/odc-cli index 9629fd327..6e7de917e 100755 --- a/cli/odc-cli +++ b/cli/odc-cli @@ -242,15 +242,15 @@ class HttpClient: @classmethod def _request(cls, method: str, url: str, data: Any = None) -> HttpResponse: - headers = cls._get_default_headers() body = json.dumps(data).encode('utf-8') if data is not None else None + headers = cls._get_default_headers(has_body=body is not None) request = Request(url, data=body, headers=headers, method=method) return cls._send(request) @classmethod def _send(cls, req: Request) -> HttpResponse: try: - with urlopen(req, timeout=2) as response: + with urlopen(req, timeout=30) as response: return cls._build_response(response) except HTTPError as err: return cls._build_response(err) @@ -283,8 +283,10 @@ class HttpClient: return HttpResponse(data=data, status=response.getcode()) @staticmethod - def _get_default_headers() -> dict[str, str]: - headers = {'Content-Type': 'application/json'} + def _get_default_headers(has_body: bool = False) -> dict[str, str]: + headers: dict[str, str] = {} + if has_body: + headers['Content-Type'] = 'application/json' if config.access_token: headers['Authorization'] = f'Bearer {config.access_token}' return headers From 9e21603456fb72353a6720a9d381b0c8be0f2bbe Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 5 Jun 2026 13:55:54 -0400 Subject: [PATCH 2/6] fix: change order of cascading subject deletion to avoid floating session keys --- apps/api/src/subjects/subjects.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/subjects/subjects.service.ts b/apps/api/src/subjects/subjects.service.ts index cd5d3b7eb..bde50db32 100644 --- a/apps/api/src/subjects/subjects.service.ts +++ b/apps/api/src/subjects/subjects.service.ts @@ -102,14 +102,14 @@ export class SubjectsService { return { success: true }; } await this.prismaClient.$transaction([ - this.prismaClient.session.deleteMany({ + this.prismaClient.instrumentRecord.deleteMany({ where: { subject: { id: subject.id } } }), - this.prismaClient.instrumentRecord.deleteMany({ + this.prismaClient.session.deleteMany({ where: { subject: { id: subject.id From e665a8ae6a210a754e87bcdedbb52072896922ab Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 5 Jun 2026 15:26:20 -0400 Subject: [PATCH 3/6] fix: rewrite the find method to fix --subject-id behaviour --- cli/odc-cli | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cli/odc-cli b/cli/odc-cli index 6e7de917e..b0308d247 100755 --- a/cli/odc-cli +++ b/cli/odc-cli @@ -407,13 +407,15 @@ class SubjectCommands: @require_token @staticmethod def find(min_date: datetime | None, subject_id: str | None) -> None: - url = build_url_with_params( - f'{config.base_url}/v1/subjects', - { - 'minDate': min_date, - 'subjectId': subject_id, - }, - ) + if subject_id is not None: + url = f'{config.base_url}/v1/subjects/{quote(subject_id, safe="")}' + else: + url = build_url_with_params( + f'{config.base_url}/v1/subjects', + { + 'minDate': min_date, + }, + ) response = HttpClient.get(url) print(response) From 71d5b84ba74ac722cde2c096c64927991f8b1303 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 5 Jun 2026 15:32:34 -0400 Subject: [PATCH 4/6] fix: make min_date arg functional in find subjects --- cli/odc-cli | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cli/odc-cli b/cli/odc-cli index b0308d247..451def120 100755 --- a/cli/odc-cli +++ b/cli/odc-cli @@ -19,7 +19,7 @@ from argparse import ( ArgumentTypeError, RawTextHelpFormatter, ) -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Callable, TypedDict from urllib.error import HTTPError, URLError from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse, quote @@ -409,14 +409,19 @@ class SubjectCommands: def find(min_date: datetime | None, subject_id: str | None) -> None: if subject_id is not None: url = f'{config.base_url}/v1/subjects/{quote(subject_id, safe="")}' + response = HttpClient.get(url) else: url = build_url_with_params( f'{config.base_url}/v1/subjects', - { - 'minDate': min_date, - }, + {} ) - response = HttpClient.get(url) + response = HttpClient.get(url) + if min_date is not None and isinstance(response.data, list): + min_date_utc = min_date.replace(tzinfo=timezone.utc) if min_date.tzinfo is None else min_date + response.data = [ + s for s in response.data + if datetime.fromisoformat(s['createdAt'].replace('Z', '+00:00')) >= min_date_utc + ] print(response) From 05a173b2616b9c7f7058d4c1991581c75c2be9c6 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 5 Jun 2026 15:49:17 -0400 Subject: [PATCH 5/6] fix: correct instrument-records minDate serialization and subject find filters claude sonnet 4.6 --- cli/odc-cli | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/odc-cli b/cli/odc-cli index 451def120..33611b640 100755 --- a/cli/odc-cli +++ b/cli/odc-cli @@ -368,7 +368,7 @@ class InstrumentRecordsCommands: url = build_url_with_params( f'{config.base_url}/v1/instrument-records', { - 'minDate': min_date, + 'minDate': min_date.isoformat() if min_date is not None else None, 'instrumentId': instrument_id, 'subjectId': subject_id, }, @@ -572,9 +572,9 @@ class CLI: find_parser.add_argument( '--min-date', type=ArgumentTypes.valid_datetime, - help='filter records created after this date (format: yyyy-mm-dd or yyyy-mm-dd hh:mm:ss)', + help='filter subjects created after this date (format: yyyy-mm-dd or yyyy-mm-dd hh:mm:ss). Cannot be used with --subject-id', ) - find_parser.add_argument('--subject-id', help='filter by subject id') + find_parser.add_argument('--subject-id', help='find a single subject by exact id. Cannot be used with --min-date') find_parser.set_defaults(fn=SubjectCommands.find) def _create_instruments_parser(self): @@ -615,7 +615,7 @@ class CLI: app_group.add_argument( '--dummy-subject-count', type=int, - help='.umber of dummy subjects to create for the demo', + help='number of dummy subjects to create for the demo', ) app_group.add_argument( '--records-per-subject', From 531da15c55401d5408cb26464a189801c5497497 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 5 Jun 2026 15:55:42 -0400 Subject: [PATCH 6/6] test: add test to confirm order of cascading delete --- .../subjects/__tests__/subjects.service.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/api/src/subjects/__tests__/subjects.service.spec.ts b/apps/api/src/subjects/__tests__/subjects.service.spec.ts index 9b5732be2..ea12d89d3 100644 --- a/apps/api/src/subjects/__tests__/subjects.service.spec.ts +++ b/apps/api/src/subjects/__tests__/subjects.service.spec.ts @@ -133,11 +133,19 @@ describe('SubjectsService', () => { subjectModel.findFirst.mockResolvedValueOnce({ id: '123' }); await subjectsService.deleteById('123', { force: true }); expect(subjectModel.delete).not.toHaveBeenCalled(); - expect(prismaClient.session.deleteMany).toHaveBeenCalled(); - expect(prismaClient.instrumentRecord.deleteMany).toHaveBeenCalled(); - expect(prismaClient.subject.deleteMany).toHaveBeenCalled(); expect(prismaClient.$transaction).toHaveBeenCalledOnce(); }); + it('should pass operations to $transaction in order: instrumentRecord, session, subject', async () => { + subjectModel.findFirst.mockResolvedValueOnce({ id: '123' }); + const instrumentRecordOp = 'instrumentRecord-op'; + const sessionOp = 'session-op'; + const subjectOp = 'subject-op'; + prismaClient.instrumentRecord.deleteMany.mockReturnValueOnce(instrumentRecordOp); + prismaClient.session.deleteMany.mockReturnValueOnce(sessionOp); + prismaClient.subject.deleteMany.mockReturnValueOnce(subjectOp); + await subjectsService.deleteById('123', { force: true }); + expect(prismaClient.$transaction).toHaveBeenCalledWith([instrumentRecordOp, sessionOp, subjectOp]); + }); it('should throw NotFoundException when subject does not exist', async () => { subjectModel.findFirst.mockResolvedValueOnce(null); await expect(subjectsService.deleteById('123')).rejects.toBeInstanceOf(NotFoundException);