Skip to content

Commit 40d32e9

Browse files
committed
feat: implement series instrument on gateway
1 parent 070cc9f commit 40d32e9

File tree

16 files changed

+234
-146
lines changed

16 files changed

+234
-146
lines changed

apps/api/src/assignments/assignments.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export class AssignmentsService {
9494
}
9595

9696
async updateById(id: string, data: UpdateAssignmentData, { ability }: EntityOperationOptions = {}) {
97+
if (data.status === 'CANCELED') {
98+
await this.gatewayService.deleteRemoteAssignment(id);
99+
}
97100
return this.assignmentModel.update({
98101
data,
99102
where: { AND: [accessibleQuery(ability, 'update', 'Assignment')], id }

apps/api/src/assignments/dto/update-assignment.dto.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { ValidationSchema } from '@douglasneuroinformatics/libnest/core';
22
import { ApiProperty } from '@nestjs/swagger';
33
import { $UpdateAssignmentData } from '@opendatacapture/schemas/assignment';
44
import type { AssignmentStatus, UpdateAssignmentData } from '@opendatacapture/schemas/assignment';
5+
import { z } from 'zod';
56

6-
@ValidationSchema($UpdateAssignmentData)
7+
@ValidationSchema(
8+
$UpdateAssignmentData.extend({
9+
status: z.literal('CANCELED')
10+
})
11+
)
712
export class UpdateAssignmentDto implements UpdateAssignmentData {
813
@ApiProperty()
9-
expiresAt?: Date;
10-
11-
@ApiProperty()
12-
status?: AssignmentStatus;
14+
status: Extract<AssignmentStatus, 'CANCELED'>;
1315
}

apps/api/src/gateway/gateway.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { InstrumentRecordsModule } from '@/instrument-records/instrument-records
77
import { InstrumentsModule } from '@/instruments/instruments.module';
88
import { SessionsModule } from '@/sessions/sessions.module';
99
import { SetupModule } from '@/setup/setup.module';
10+
import { VirtualizationModule } from '@/virtualization/virtualization.module';
1011

1112
import { GatewayController } from './gateway.controller';
1213
import { GatewayService } from './gateway.service';
@@ -44,7 +45,8 @@ import { GatewaySynchronizer } from './gateway.synchronizer';
4445
InstrumentRecordsModule,
4546
InstrumentsModule,
4647
SessionsModule,
47-
SetupModule
48+
SetupModule,
49+
VirtualizationModule
4850
],
4951
providers: [GatewayService, GatewaySynchronizer]
5052
})

apps/api/src/gateway/gateway.service.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { HybridCrypto } from '@douglasneuroinformatics/libcrypto';
22
import { HttpService } from '@nestjs/axios';
3-
import { BadGatewayException, HttpStatus, Injectable, Logger, NotImplementedException } from '@nestjs/common';
3+
import { BadGatewayException, HttpStatus, Injectable, Logger } from '@nestjs/common';
44
import { $MutateAssignmentResponseBody, $RemoteAssignment } from '@opendatacapture/schemas/assignment';
55
import type {
66
Assignment,
@@ -27,10 +27,6 @@ export class GatewayService {
2727

2828
async createRemoteAssignment(assignment: Assignment, publicKey: CryptoKey): Promise<MutateAssignmentResponseBody> {
2929
const instrument = await this.instrumentsService.findBundleById(assignment.instrumentId);
30-
if (instrument.kind === 'SERIES') {
31-
throw new NotImplementedException('Cannot create remote assignment for series instrument');
32-
}
33-
3430
const response = await this.httpService.axiosRef.post(`/api/assignments`, {
3531
...assignment,
3632
instrumentContainer: instrument,

apps/api/src/gateway/gateway.synchronizer.ts

Lines changed: 77 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { $Json } from '@opendatacapture/schemas/core';
66
import { AssignmentsService } from '@/assignments/assignments.service';
77
import { ConfigurationService } from '@/configuration/configuration.service';
88
import { InstrumentRecordsService } from '@/instrument-records/instrument-records.service';
9+
import { InstrumentsService } from '@/instruments/instruments.service';
910
import { SessionsService } from '@/sessions/sessions.service';
1011
import { SetupService } from '@/setup/setup.service';
12+
import { VirtualizationService } from '@/virtualization/virtualization.service';
1113

1214
import { GatewayService } from './gateway.service';
1315

@@ -20,9 +22,11 @@ export class GatewaySynchronizer implements OnApplicationBootstrap {
2022
configurationService: ConfigurationService,
2123
private readonly assignmentsService: AssignmentsService,
2224
private readonly gatewayService: GatewayService,
25+
private readonly instrumentsService: InstrumentsService,
2326
private readonly instrumentRecordsService: InstrumentRecordsService,
2427
private readonly sessionsService: SessionsService,
25-
private readonly setupService: SetupService
28+
private readonly setupService: SetupService,
29+
private readonly virtualizationService: VirtualizationService
2630
) {
2731
this.refreshInterval = configurationService.get('GATEWAY_REFRESH_INTERVAL');
2832
}
@@ -37,54 +41,98 @@ export class GatewaySynchronizer implements OnApplicationBootstrap {
3741
}
3842
const assignment = await this.assignmentsService.findById(remoteAssignment.id);
3943

40-
const completedAt = remoteAssignment.completedAt;
41-
const symmetricKey = remoteAssignment.symmetricKey;
42-
if (!completedAt) {
44+
if (!remoteAssignment.completedAt) {
4345
this.logger.error(`Field 'completedAt' is null for assignment '${assignment.id}'`);
4446
return;
45-
} else if (!symmetricKey) {
47+
} else if (!remoteAssignment.symmetricKey) {
4648
this.logger.error(`Field 'symmetricKey' is null for assignment '${assignment.id}'`);
4749
return;
4850
}
4951

50-
const data = await $Json.parseAsync(
51-
JSON.parse(
52-
await HybridCrypto.decrypt({
53-
cipherText: remoteAssignment.encryptedData,
54-
privateKey: await HybridCrypto.deserializePrivateKey(assignment.encryptionKeyPair.privateKey),
55-
symmetricKey
56-
})
57-
)
58-
);
52+
const instrument = await this.instrumentsService.findById(assignment.instrumentId);
5953

6054
const session = await this.sessionsService.create({
61-
date: completedAt,
55+
date: remoteAssignment.completedAt,
6256
groupId: remoteAssignment.groupId ?? null,
6357
subjectData: {
6458
id: assignment.subjectId
6559
},
6660
type: 'REMOTE'
6761
});
6862

69-
const record = await this.instrumentRecordsService.create({
70-
assignmentId: assignment.id,
71-
data,
72-
date: completedAt,
73-
groupId: assignment.groupId ?? undefined,
74-
instrumentId: assignment.instrumentId,
75-
sessionId: session.id,
76-
subjectId: assignment.subjectId
77-
});
63+
const cipherTexts: string[] = [];
64+
const symmetricKeys: string[] = [];
7865

79-
this.logger.log(`Created record with ID: ${record.id}`);
66+
if (instrument.kind === 'SERIES') {
67+
if (!(remoteAssignment.encryptedData.startsWith('$') && remoteAssignment.symmetricKey.startsWith('$'))) {
68+
this.logger.error({ remoteAssignment });
69+
throw new InternalServerErrorException('Malformed remote assignment for series instrument');
70+
}
71+
cipherTexts.push(...remoteAssignment.encryptedData.slice(1).split('$'));
72+
symmetricKeys.push(...remoteAssignment.symmetricKey.slice(1).split('$'));
73+
if (cipherTexts.length !== instrument.content.length) {
74+
throw new InternalServerErrorException(
75+
`Expected length of cypher texts '${cipherTexts.length}' to match length of series instrument content '${symmetricKeys.length}'`
76+
);
77+
} else if (symmetricKeys.length !== instrument.content.length) {
78+
throw new InternalServerErrorException(
79+
`Expected length of symmetric keys '${cipherTexts.length}' to match length of series instrument content '${symmetricKeys.length}'`
80+
);
81+
}
82+
} else if (remoteAssignment.encryptedData.includes('$') || remoteAssignment.symmetricKey.includes('$')) {
83+
this.logger.error({ remoteAssignment });
84+
throw new InternalServerErrorException('Malformed remote assignment for scalar instrument');
85+
} else {
86+
cipherTexts.push(remoteAssignment.encryptedData);
87+
symmetricKeys.push(remoteAssignment.symmetricKey);
88+
}
89+
90+
const createdRecordIds: string[] = [];
8091
try {
92+
for (let i = 0; i < cipherTexts.length; i++) {
93+
const cipherText = cipherTexts[i]!;
94+
const symmetricKey = symmetricKeys[i]!;
95+
const data = await $Json.parseAsync(
96+
JSON.parse(
97+
await HybridCrypto.decrypt({
98+
cipherText: Buffer.from(cipherText, 'base64'),
99+
privateKey: await HybridCrypto.deserializePrivateKey(assignment.encryptionKeyPair.privateKey),
100+
symmetricKey: Buffer.from(symmetricKey, 'base64')
101+
})
102+
)
103+
);
104+
const record = await this.instrumentRecordsService.create({
105+
assignmentId: assignment.id,
106+
data,
107+
date: remoteAssignment.completedAt,
108+
groupId: assignment.groupId ?? undefined,
109+
instrumentId:
110+
instrument.kind === 'SERIES'
111+
? this.instrumentsService.generateScalarInstrumentId({ internal: instrument.content[i]! })
112+
: instrument.id,
113+
sessionId: session.id,
114+
subjectId: assignment.subjectId
115+
});
116+
this.logger.log(`Created record with ID: ${record.id}`);
117+
createdRecordIds.push(record.id);
118+
}
81119
await this.gatewayService.deleteRemoteAssignment(assignment.id);
82120
} catch (err) {
83-
await this.instrumentRecordsService.deleteById(record.id);
84-
this.logger.log(`Deleted Record with ID: ${record.id}`);
85-
throw new InternalServerErrorException('Failed to Delete Remote Assignments', {
86-
cause: err
121+
this.logger.error({
122+
data: {
123+
assignment,
124+
cipherTexts,
125+
remoteAssignment,
126+
symmetricKeys
127+
},
128+
message: 'Failed to Process Data'
87129
});
130+
this.logger.error(err);
131+
for (const id of createdRecordIds) {
132+
await this.instrumentRecordsService.deleteById(id);
133+
this.logger.log(`Deleted Record with ID: ${id}`);
134+
}
135+
throw err;
88136
}
89137
}
90138

apps/api/src/instruments/instruments.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,18 +171,18 @@ export class InstrumentsService {
171171
}));
172172
}
173173

174-
private generateInstrumentId(instrument: AnyInstrument) {
174+
generateInstrumentId(instrument: AnyInstrument) {
175175
if (isScalarInstrument(instrument)) {
176176
return this.generateScalarInstrumentId(instrument);
177177
}
178178
return this.generateSeriesInstrumentId(instrument);
179179
}
180180

181-
private generateScalarInstrumentId({ internal: { edition, name } }: Pick<AnyScalarInstrument, 'internal'>) {
181+
generateScalarInstrumentId({ internal: { edition, name } }: Pick<AnyScalarInstrument, 'internal'>) {
182182
return this.cryptoService.hash(`${name}-${edition}`);
183183
}
184184

185-
private generateSeriesInstrumentId(instrument: SeriesInstrument) {
185+
generateSeriesInstrumentId(instrument: SeriesInstrument) {
186186
return this.cryptoService.hash(instrument.content.map(({ edition, name }) => `${name}-${edition}`).join('--'));
187187
}
188188

apps/gateway/prisma/schema.prisma

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,16 @@ datasource db {
1313
}
1414

1515
model RemoteAssignmentModel {
16-
id String @id
17-
createdAt DateTime @default(now())
18-
completedAt DateTime?
19-
expiresAt DateTime
20-
groupId String?
21-
instrumentBundle String
22-
instrumentKind String
23-
instrumentId String
24-
rawPublicKey Bytes
25-
symmetricKey Bytes?
26-
encryptedData Bytes?
27-
status String
28-
subjectId String
29-
url String
16+
id String @id
17+
createdAt DateTime @default(now())
18+
completedAt DateTime?
19+
expiresAt DateTime
20+
groupId String?
21+
rawPublicKey Bytes
22+
symmetricKey String?
23+
encryptedData String?
24+
targetStringified String
25+
status String
26+
subjectId String
27+
url String
3028
}

apps/gateway/src/Root.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,50 @@ import { LanguageToggle, ThemeToggle } from '@douglasneuroinformatics/libui/comp
55
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
66
import { InstrumentRenderer, type InstrumentSubmitHandler } from '@opendatacapture/instrument-renderer';
77
import { Branding } from '@opendatacapture/react-core';
8-
import type { InstrumentKind } from '@opendatacapture/runtime-core';
9-
import type { UpdateAssignmentData } from '@opendatacapture/schemas/assignment';
8+
import type { UpdateRemoteAssignmentData } from '@opendatacapture/schemas/assignment';
9+
import type { InstrumentBundleContainer } from '@opendatacapture/schemas/instrument';
1010
import axios from 'axios';
1111

1212
import './services/axios';
1313
import './services/i18n';
1414

1515
export type RootProps = {
16-
bundle: string;
1716
id: string;
18-
kind: Exclude<InstrumentKind, 'SERIES'>;
17+
initialSeriesIndex?: number;
18+
target: InstrumentBundleContainer;
1919
token: string;
2020
};
2121

22-
export const Root = ({ bundle, id, kind, token }: RootProps) => {
22+
export const Root = ({ id, initialSeriesIndex, target, token }: RootProps) => {
2323
const ref = useRef<HTMLDivElement>(null);
2424
const notifications = useNotificationsStore();
2525

2626
useEffect(() => {
2727
ref.current!.style.display = 'flex';
2828
}, []);
2929

30-
const handleSubmit: InstrumentSubmitHandler = async ({ data }) => {
31-
await axios.patch(
32-
`/api/assignments/${id}`,
33-
{
34-
data,
30+
const handleSubmit: InstrumentSubmitHandler = async (result) => {
31+
let updateData: UpdateRemoteAssignmentData;
32+
if (target.kind === 'SERIES' && result.kind === 'SERIES') {
33+
updateData = {
34+
...result,
35+
status: result.instrumentId === target.items.at(-1)?.id ? 'COMPLETE' : undefined
36+
};
37+
} else if (target.kind !== 'SERIES') {
38+
updateData = {
39+
data: result.data,
40+
kind: 'SCALAR',
3541
status: 'COMPLETE'
36-
} satisfies UpdateAssignmentData,
37-
{
38-
headers: {
39-
Authorization: `Bearer ${token}`
40-
}
42+
};
43+
} else {
44+
notifications.addNotification({ message: 'Internal Server Error', type: 'error' });
45+
return;
46+
}
47+
await axios.patch(`/api/assignments/${id}`, updateData, {
48+
headers: {
49+
Authorization: `Bearer ${token}`
4150
}
42-
);
51+
});
4352
notifications.addNotification({ type: 'success' });
4453
};
4554

@@ -61,7 +70,12 @@ export const Root = ({ bundle, id, kind, token }: RootProps) => {
6170
</div>
6271
</header>
6372
<main className="container flex min-h-0 max-w-3xl flex-grow flex-col pb-16 pt-32 xl:max-w-5xl">
64-
<InstrumentRenderer className="min-h-full w-full" target={{ bundle, id, kind }} onSubmit={handleSubmit} />
73+
<InstrumentRenderer
74+
className="min-h-full w-full"
75+
initialSeriesIndex={initialSeriesIndex}
76+
target={target}
77+
onSubmit={handleSubmit}
78+
/>
6579
</main>
6680
<NotificationHub />
6781
</div>

0 commit comments

Comments
 (0)