diff --git a/apps/api/src/auth/ability.factory.ts b/apps/api/src/auth/ability.factory.ts
index c0306d40a..ab5c6f4cd 100644
--- a/apps/api/src/auth/ability.factory.ts
+++ b/apps/api/src/auth/ability.factory.ts
@@ -27,6 +27,12 @@ export class AbilityFactory {
ability.can('manage', 'Assignment', { groupId: { in: groupIds } });
ability.can('manage', 'Group', { id: { in: groupIds } });
ability.can('read', 'Instrument');
+ // Group managers may assemble series instruments on the fly and delete ones they created. A
+ // series is a bundle of other instruments, not a shared platform asset. The delete ability is
+ // scoped to manually-created (non-repo) instruments so repo instruments shared across groups can
+ // never be removed; the service further restricts deletion to series instruments specifically.
+ ability.can('create', 'Instrument');
+ ability.can('delete', 'Instrument', { sourceRepoId: null });
ability.can('read', 'InstrumentRepo', { groupIds: { hasSome: groupIds } });
ability.can('create', 'InstrumentRecord');
ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } });
diff --git a/apps/api/src/groups/__tests__/groups.service.spec.ts b/apps/api/src/groups/__tests__/groups.service.spec.ts
index 3e6d4a1e1..072ca83b2 100644
--- a/apps/api/src/groups/__tests__/groups.service.spec.ts
+++ b/apps/api/src/groups/__tests__/groups.service.spec.ts
@@ -59,6 +59,20 @@ describe('GroupsService', () => {
});
});
+ it('should drop accessibleInstrumentIds that no longer exist before setting the relation', async () => {
+ groupModel.findFirst.mockResolvedValueOnce({ name: 'Test Group', settings: {} });
+ // 'deleted' was removed since the client loaded the group and must not reach the relation set.
+ instrumentModel.findMany.mockResolvedValueOnce([{ id: 'live-1' }, { id: 'live-2' }]);
+ await groupsService.updateById('123', { accessibleInstrumentIds: ['live-1', 'deleted', 'live-2'] });
+ expect(instrumentModel.findMany).toHaveBeenCalledWith({
+ select: { id: true },
+ where: { id: { in: ['live-1', 'deleted', 'live-2'] } }
+ });
+ expect(groupModel.update.mock.lastCall?.[0]).toMatchObject({
+ data: { accessibleInstruments: { set: [{ id: 'live-1' }, { id: 'live-2' }] } }
+ });
+ });
+
it('should not throw when the name is unchanged', async () => {
groupModel.findFirst.mockResolvedValueOnce({ name: 'Test Group', settings: {} });
await groupsService.updateById('123', { name: 'Test Group' });
diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts
index bcceebb02..248544e5c 100644
--- a/apps/api/src/groups/groups.service.ts
+++ b/apps/api/src/groups/groups.service.ts
@@ -82,11 +82,23 @@ export class GroupsService {
throw new ConflictException(`Group with name '${data.name}' already exists!`);
}
+ // Guard against stale client state: an instrument may have been deleted since the client loaded the
+ // group (the deleted id can linger in the client's accessible list). Connecting a non-existent
+ // instrument makes Prisma fail the relation update, so restrict the set to ids that still exist.
+ let validInstrumentIds: string[] | undefined;
+ if (accessibleInstrumentIds) {
+ const existingInstruments = await this.instrumentModel.findMany({
+ select: { id: true },
+ where: { id: { in: accessibleInstrumentIds } }
+ });
+ validInstrumentIds = existingInstruments.map(({ id }) => id);
+ }
+
return this.groupModel.update({
data: {
- accessibleInstruments: accessibleInstrumentIds
+ accessibleInstruments: validInstrumentIds
? {
- set: accessibleInstrumentIds.map((id) => ({ id }))
+ set: validInstrumentIds.map((id) => ({ id }))
}
: undefined,
instrumentRepos: instrumentRepoIds
diff --git a/apps/api/src/instruments/__tests__/instruments.service.spec.ts b/apps/api/src/instruments/__tests__/instruments.service.spec.ts
index 00961a654..dc52ae06b 100644
--- a/apps/api/src/instruments/__tests__/instruments.service.spec.ts
+++ b/apps/api/src/instruments/__tests__/instruments.service.spec.ts
@@ -2,32 +2,291 @@ import { CryptoService, getModelToken, LoggingService, VirtualizationService } f
import type { Model } from '@douglasneuroinformatics/libnest';
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
+import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
-import { beforeEach, describe, expect, it } from 'vitest';
+import { bundle } from '@opendatacapture/instrument-bundler';
+import type { SeriesInstrument } from '@opendatacapture/runtime-core';
+import type { WithID } from '@opendatacapture/schemas/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { InstrumentsService } from '../instruments.service';
+// Avoid bundling a real instrument (esbuild) during unit tests; `createSeries` only needs `create` to be
+// called with whatever the bundler produces.
+vi.mock('@opendatacapture/instrument-bundler', () => ({
+ bundle: vi.fn(() => Promise.resolve('__BUNDLE__'))
+}));
+
+// A multilingual series instance as returned by `find`, containing two forms.
+const existingSeries: WithID = {
+ __runtimeVersion: 1,
+ content: {
+ items: [
+ { edition: 1, name: 'FORM_A' },
+ { edition: 1, name: 'FORM_B' }
+ ]
+ },
+ details: {
+ description: { en: 'An existing series', fr: 'Une série existante' },
+ license: 'UNLICENSED',
+ title: { en: 'Existing Series', fr: 'Série existante' }
+ },
+ id: 'existing-series-id',
+ kind: 'SERIES',
+ language: ['en', 'fr'],
+ tags: { en: ['Series'], fr: ['Série'] }
+};
+
describe('InstrumentsService', () => {
let instrumentsService: InstrumentsService;
+ let cryptoService: MockedInstance;
let instrumentModel: MockedInstance>;
+ let instrumentRecordModel: MockedInstance>;
+ let groupModel: MockedInstance>;
+ let virtualizationService: MockedInstance>;
beforeEach(async () => {
+ vi.mocked(bundle).mockClear();
+
const moduleRef = await Test.createTestingModule({
providers: [
InstrumentsService,
MockFactory.createForModelToken(getModelToken('Group')),
MockFactory.createForModelToken(getModelToken('Instrument')),
+ MockFactory.createForModelToken(getModelToken('InstrumentRecord')),
MockFactory.createForService(CryptoService),
MockFactory.createForService(LoggingService),
MockFactory.createForService(VirtualizationService)
]
}).compile();
instrumentsService = moduleRef.get(InstrumentsService);
+ cryptoService = moduleRef.get(CryptoService);
instrumentModel = moduleRef.get(getModelToken('Instrument'));
+ instrumentRecordModel = moduleRef.get(getModelToken('InstrumentRecord'));
+ groupModel = moduleRef.get(getModelToken('Group'));
+ virtualizationService = moduleRef.get(VirtualizationService);
+ // `getInstrumentInstance` reads/writes an instance cache on the virtualization context.
+ (virtualizationService as { context: unknown }).context = { __resolveImport: vi.fn(), instruments: new Map() };
});
it('should be defined', () => {
expect(instrumentsService).toBeDefined();
expect(instrumentModel).toBeDefined();
});
+
+ describe('createSeries', () => {
+ it('asks for confirmation (without creating) when another series already has the same forms', async () => {
+ vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
+ const createSpy = vi.spyOn(instrumentsService, 'create');
+
+ // Same two forms as `existingSeries` but in a different order and under a different name.
+ const result = await instrumentsService.createSeries({
+ details: { title: 'My New Series' },
+ items: [
+ { edition: 1, name: 'FORM_B' },
+ { edition: 1, name: 'FORM_A' }
+ ],
+ language: 'en'
+ });
+
+ expect(result).toEqual({
+ existingTitle: { en: 'Existing Series', fr: 'Série existante' },
+ outcome: 'duplicate'
+ });
+ expect(createSpy).not.toHaveBeenCalled();
+ });
+
+ it('creates the series when the duplicate is explicitly confirmed', async () => {
+ vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
+ const createSpy = vi.spyOn(instrumentsService, 'create').mockResolvedValue({ id: 'new-id' } as any);
+
+ const result = await instrumentsService.createSeries({
+ confirmDuplicate: true,
+ details: { title: 'My New Series' },
+ items: [
+ { edition: 1, name: 'FORM_A' },
+ { edition: 1, name: 'FORM_B' }
+ ],
+ language: 'en'
+ });
+
+ expect(createSpy).toHaveBeenCalledWith({ bundle: '__BUNDLE__' });
+ expect(result).toEqual({ instrumentId: 'new-id', outcome: 'created' });
+ });
+
+ it('creates the series directly when no existing series shares its forms', async () => {
+ vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
+ const createSpy = vi.spyOn(instrumentsService, 'create').mockResolvedValue({ id: 'fresh-id' } as any);
+
+ const result = await instrumentsService.createSeries({
+ details: { title: 'Totally New' },
+ items: [
+ { edition: 1, name: 'FORM_C' },
+ { edition: 1, name: 'FORM_D' }
+ ],
+ language: 'en'
+ });
+
+ expect(createSpy).toHaveBeenCalledWith({ bundle: '__BUNDLE__' });
+ expect(result).toEqual({ instrumentId: 'fresh-id', outcome: 'created' });
+ });
+
+ it('generates a valid, unilingual series source payload with selected items in order', async () => {
+ vi.spyOn(instrumentsService, 'find').mockResolvedValue([]);
+ vi.spyOn(instrumentsService, 'create').mockResolvedValue({ id: 'generated-id' } as any);
+
+ await instrumentsService.createSeries({
+ clientDetails: { instructions: ['Complete the instruments in order.'] },
+ details: { description: 'Optional description', title: 'Generated Series' },
+ items: [
+ { edition: 2, name: 'FORM_B' },
+ { edition: 1, name: 'FORM_A' }
+ ],
+ language: 'en'
+ });
+
+ expect(bundle).toHaveBeenCalledTimes(1);
+
+ const [bundleOptions] = vi.mocked(bundle).mock.calls[0]!;
+ expect(bundleOptions.minify).toBe(true);
+ expect(bundleOptions.inputs).toHaveLength(1);
+
+ expect(bundleOptions.inputs[0]!.content).toEqual(expect.any(String));
+ const source = bundleOptions.inputs[0]!.content as string;
+ const match = /^export default (.*);$/.exec(source);
+ expect(match).not.toBeNull();
+
+ const definition = JSON.parse(match![1]!);
+ expect(definition.kind).toBe('SERIES');
+ // The details/instructions/language are stored verbatim in the caller's language — nothing is
+ // duplicated across languages or hardcoded.
+ expect(definition.language).toBe('en');
+ expect(definition.clientDetails).toEqual({ instructions: ['Complete the instruments in order.'] });
+ expect(definition.details).toMatchObject({
+ description: 'Optional description',
+ title: 'Generated Series'
+ });
+ expect(definition.tags).toEqual(['Series']);
+ expect(definition.content.items).toEqual([
+ { edition: 2, name: 'FORM_B' },
+ { edition: 1, name: 'FORM_A' }
+ ]);
+ });
+
+ it('runs the generated bundle through create and stores the validated series instrument', async () => {
+ const items = [
+ { edition: 1, name: 'FORM_A' },
+ { edition: 1, name: 'FORM_B' }
+ ];
+ const instance = {
+ __runtimeVersion: 1,
+ content: { items },
+ details: {
+ description: 'Stored Series',
+ license: 'UNLICENSED',
+ title: 'Stored Series'
+ },
+ kind: 'SERIES',
+ language: 'en',
+ tags: ['Series']
+ } as const;
+ const id = `hash:${JSON.stringify({ content: instance.content, title: instance.details.title })}`;
+
+ vi.spyOn(instrumentsService, 'find').mockResolvedValue([]);
+ vi.spyOn(cryptoService, 'hash').mockImplementation((value) => `hash:${value}`);
+ virtualizationService.eval.mockResolvedValue({ isErr: () => false, value: instance } as any);
+ instrumentModel.exists.mockResolvedValueOnce(false).mockResolvedValue(true);
+
+ const result = await instrumentsService.createSeries({
+ confirmDuplicate: true,
+ details: { title: 'Stored Series' },
+ items,
+ language: 'en'
+ });
+
+ expect(virtualizationService.eval).toHaveBeenCalledWith('__BUNDLE__');
+ expect(instrumentModel.exists).toHaveBeenNthCalledWith(1, { id });
+ expect(instrumentModel.exists).toHaveBeenNthCalledWith(2, { id: 'hash:FORM_A-1' });
+ expect(instrumentModel.exists).toHaveBeenNthCalledWith(3, { id: 'hash:FORM_B-1' });
+ expect(instrumentModel.create).toHaveBeenCalledWith({ data: { bundle: '__BUNDLE__', id } });
+ expect(result).toEqual({ instrumentId: id, outcome: 'created' });
+ });
+
+ it('rejects a title that is already used (case-insensitively) by another instrument', async () => {
+ vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
+ const createSpy = vi.spyOn(instrumentsService, 'create');
+
+ await expect(
+ instrumentsService.createSeries({
+ details: { title: 'existing series' },
+ items: [
+ { edition: 1, name: 'FORM_X' },
+ { edition: 1, name: 'FORM_Y' }
+ ],
+ language: 'en'
+ })
+ ).rejects.toThrow(ConflictException);
+ expect(createSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('generateSeriesInstrumentId', () => {
+ it('includes the series title so confirmed duplicate form sets can be distinct', () => {
+ vi.spyOn(cryptoService, 'hash').mockImplementation((value) => value);
+
+ const first = {
+ ...existingSeries,
+ details: { ...existingSeries.details, title: 'First Series' }
+ };
+ const second = {
+ ...existingSeries,
+ details: { ...existingSeries.details, title: 'Second Series' }
+ };
+
+ expect(instrumentsService.generateSeriesInstrumentId(first)).not.toBe(
+ instrumentsService.generateSeriesInstrumentId(second)
+ );
+ expect(cryptoService.hash).toHaveBeenCalledWith(
+ JSON.stringify({ content: first.content, title: first.details.title })
+ );
+ });
+ });
+
+ describe('deleteById', () => {
+ it('throws when the instrument does not exist', async () => {
+ instrumentModel.findFirst.mockResolvedValue(null);
+ await expect(instrumentsService.deleteById('missing')).rejects.toThrow(NotFoundException);
+ });
+
+ it('refuses to delete a non-series (scalar) instrument', async () => {
+ instrumentModel.findFirst.mockResolvedValue({ bundle: '__BUNDLE__', id: 'scalar' });
+ virtualizationService.eval.mockResolvedValue({
+ isErr: () => false,
+ value: { internal: { edition: 1, name: 'FORM_A' }, kind: 'FORM' }
+ } as any);
+
+ await expect(instrumentsService.deleteById('scalar')).rejects.toThrow(ForbiddenException);
+ expect(instrumentModel.delete).not.toHaveBeenCalled();
+ });
+
+ it('deletes a series instrument and detaches it from every group', async () => {
+ instrumentModel.findFirst.mockResolvedValue({ bundle: '__BUNDLE__', id: 'target' });
+ virtualizationService.eval.mockResolvedValue({
+ isErr: () => false,
+ value: { content: { items: [] }, kind: 'SERIES' }
+ } as any);
+ groupModel.findMany.mockResolvedValue([{ accessibleInstrumentIds: ['other', 'target'], id: 'g1' }]);
+
+ const result = await instrumentsService.deleteById('target');
+
+ expect(groupModel.update).toHaveBeenCalledWith({
+ data: { accessibleInstrumentIds: { set: ['other'] } },
+ where: { id: 'g1' }
+ });
+ expect(instrumentModel.delete).toHaveBeenCalledWith({ where: { id: 'target' } });
+ expect(result).toEqual({ id: 'target' });
+ // Series instruments never have records of their own, so record counts are irrelevant here.
+ expect(instrumentRecordModel.count).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/apps/api/src/instruments/instruments.controller.ts b/apps/api/src/instruments/instruments.controller.ts
index a5309a6ac..6efc2085e 100644
--- a/apps/api/src/instruments/instruments.controller.ts
+++ b/apps/api/src/instruments/instruments.controller.ts
@@ -1,8 +1,15 @@
import { CurrentUser } from '@douglasneuroinformatics/libnest';
-import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { InstrumentKind } from '@opendatacapture/runtime-core';
-import type { InstrumentBundleContainer, InstrumentInfo } from '@opendatacapture/schemas/instrument';
+// Imported as a value (not a type-only import) so it doubles as the validation schema for the request
+// body while also annotating its type — no dedicated DTO class is needed.
+import { $CreateSeriesInstrumentData } from '@opendatacapture/schemas/instrument';
+import type {
+ CreateSeriesInstrumentResult,
+ InstrumentBundleContainer,
+ InstrumentInfo
+} from '@opendatacapture/schemas/instrument';
import type { AppAbility } from '@/auth/auth.types';
import { RouteAccess } from '@/core/decorators/route-access.decorator';
@@ -22,6 +29,23 @@ export class InstrumentsController {
return this.instrumentsService.create(data);
}
+ @ApiOperation({ summary: 'Create Series Instrument' })
+ @Post('series')
+ @RouteAccess({ action: 'create', subject: 'Instrument' })
+ createSeries(
+ @Body() data: $CreateSeriesInstrumentData,
+ @CurrentUser('ability') ability: AppAbility
+ ): Promise {
+ return this.instrumentsService.createSeries(data, { ability });
+ }
+
+ @ApiOperation({ summary: 'Delete Instrument' })
+ @Delete(':id')
+ @RouteAccess({ action: 'delete', subject: 'Instrument' })
+ delete(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility): Promise {
+ return this.instrumentsService.deleteById(id, { ability });
+ }
+
@ApiOperation({ summary: 'Get Instrument Bundle' })
@Get('bundle/:id')
@RouteAccess({ action: 'read', subject: 'Instrument' })
diff --git a/apps/api/src/instruments/instruments.service.ts b/apps/api/src/instruments/instruments.service.ts
index 7c6a4b92d..a8c7cfa0b 100644
--- a/apps/api/src/instruments/instruments.service.ts
+++ b/apps/api/src/instruments/instruments.service.ts
@@ -2,25 +2,36 @@ import { CryptoService, InjectModel, LoggingService, VirtualizationService } fro
import type { Model } from '@douglasneuroinformatics/libnest';
import {
ConflictException,
+ ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
UnprocessableEntityException
} from '@nestjs/common';
+import { bundle } from '@opendatacapture/instrument-bundler';
import { getSeriesInstrumentItems, isScalarInstrument, isSeriesInstrument } from '@opendatacapture/instrument-utils';
import type {
AnyInstrument,
AnyScalarInstrument,
+ ClientInstrumentDetails,
InstrumentKind,
+ InstrumentLanguage,
+ InstrumentUIOption,
+ Language,
+ ScalarInstrumentInternal,
SeriesInstrument,
SomeInstrument
} from '@opendatacapture/runtime-core';
import type { WithID } from '@opendatacapture/schemas/core';
import { $AnyInstrument } from '@opendatacapture/schemas/instrument';
import type {
+ $CreateSeriesInstrumentData,
+ CreateSeriesInstrumentResult,
InstrumentBundleContainer,
InstrumentInfo,
- ScalarInstrumentBundleContainer
+ ScalarInstrumentBundleContainer,
+ ScalarInstrumentInfo,
+ SeriesInstrumentInfo
} from '@opendatacapture/schemas/instrument';
import { pick } from 'lodash-es';
@@ -30,6 +41,9 @@ import type { EntityOperationOptions } from '@/core/types';
import { CreateInstrumentDto } from './dto/create-instrument.dto';
+/** The localized "series" tag applied to every generated series instrument. */
+const seriesTag = (language: Language): string => (language === 'fr' ? 'Série' : 'Series');
+
type InstrumentVirtualizationContext = {
__resolveImport: (specifier: string) => string;
instruments: Map>;
@@ -45,6 +59,7 @@ export class InstrumentsService {
constructor(
@InjectModel('Group') private readonly groupModel: Model<'Group'>,
@InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'>,
+ @InjectModel('InstrumentRecord') private readonly instrumentRecordModel: Model<'InstrumentRecord'>,
private readonly cryptoService: CryptoService,
private readonly loggingService: LoggingService,
private readonly virtualizationService: VirtualizationService
@@ -106,6 +121,69 @@ export class InstrumentsService {
return { ...instance, id };
}
+ /**
+ * Assemble a new series instrument from existing scalar instruments. The title must be unique among
+ * every instrument on the platform; the series bundle is generated server-side and run through the
+ * same validation/creation path as any other instrument.
+ *
+ * If another series already contains the exact same set of items (regardless of order or name) and the
+ * caller has not opted to proceed, no instrument is created — instead a `duplicate` result is returned
+ * with the existing series' title, so the client can ask the user whether they really want a duplicate.
+ */
+ async createSeries(
+ { clientDetails, confirmDuplicate, details, items, language }: $CreateSeriesInstrumentData,
+ options: EntityOperationOptions = {}
+ ): Promise {
+ if (await this.isInstrumentTitleTaken(details.title)) {
+ throw new ConflictException('An instrument with the same title already exists');
+ }
+ if (!confirmDuplicate) {
+ const existingTitle = await this.findSeriesTitleWithSameItems(items, options);
+ if (existingTitle !== null) {
+ return { existingTitle, outcome: 'duplicate' };
+ }
+ }
+ const source = this.generateSeriesInstrumentSource({ clientDetails, details, items, language });
+ const generatedBundle = await bundle({ inputs: [{ content: source, name: 'index.ts' }], minify: true });
+ const created = await this.create({ bundle: generatedBundle });
+ return { instrumentId: created.id, outcome: 'created' };
+ }
+
+ /**
+ * Delete a series instrument the caller is permitted to remove, detaching it from any group that
+ * exposes it. Only series instruments may be deleted: they are on-the-fly bundles of other
+ * instruments and never have records of their own (records belong to their constituent members).
+ * Scalar instruments are shared platform assets and are never removed through this path.
+ */
+ async deleteById(id: string, { ability }: EntityOperationOptions = {}): Promise<{ id: string }> {
+ const instrument = await this.instrumentModel.findFirst({
+ where: { AND: [accessibleQuery(ability, 'delete', 'Instrument')], id }
+ });
+ if (!instrument) {
+ throw new NotFoundException(`Failed to find instrument with ID: ${id}`);
+ }
+ const instance = await this.getInstrumentInstance(instrument);
+ if (!isSeriesInstrument(instance)) {
+ throw new ForbiddenException('Only series instruments can be deleted');
+ }
+ // Mongo does not cascade scalar-list relations, so pull the id from every group that exposes it
+ // before removing the instrument itself.
+ const groups = await this.groupModel.findMany({
+ select: { accessibleInstrumentIds: true, id: true },
+ where: { accessibleInstrumentIds: { has: id } }
+ });
+ await Promise.all(
+ groups.map((group) =>
+ this.groupModel.update({
+ data: { accessibleInstrumentIds: { set: group.accessibleInstrumentIds.filter((value) => value !== id) } },
+ where: { id: group.id }
+ })
+ )
+ );
+ await this.instrumentModel.delete({ where: { id } });
+ return { id };
+ }
+
async find(
query: InstrumentQuery = {},
{ ability }: EntityOperationOptions = {}
@@ -178,35 +256,50 @@ export class InstrumentsService {
const instances = await this.find(query, options);
const sourceMap = await this.buildInstrumentSourceMap(instances.map((instance) => instance.id));
+ // Series resolve their `seriesItems` against the scalar instruments they reference. A `kind` filter can
+ // exclude those scalars from `instances`, so when the result set contains series we build the lookup
+ // from the full instrument set instead — otherwise a series' items would resolve to nothing.
+ const scalarSource = query.kind && instances.some(isSeriesInstrument) ? await this.find({}, options) : instances;
+ const scalarInstrumentIds = new Map(
+ scalarSource.flatMap((instance) =>
+ isScalarInstrument(instance) && instance.internal
+ ? [[`${instance.internal.name}:${instance.internal.edition}`, instance.id] as const]
+ : []
+ )
+ );
const results = new Map();
for (const instance of instances) {
- const info = pick(instance, [
- '__runtimeVersion',
- 'clientDetails',
- 'details',
- 'id',
- 'internal',
- 'kind',
- 'language',
- 'tags'
- ]);
+ const base = pick(instance, ['__runtimeVersion', 'clientDetails', 'details', 'id', 'language', 'tags']);
// Expose the source repo id whenever the instrument came from a repo (so it can be filtered per
// group). The name may be null for legacy instruments imported before names were stored; the
- // client renders those as "uploaded manually" while still treating them as repo-sourced.
- const source = sourceMap.get(info.id);
- if (source?.sourceRepoId) {
- (info as InstrumentInfo).sourceRepo = {
- id: source.sourceRepoId,
- name: source.sourceRepoName ?? null
+ // client still treats those as repo-sourced via their id.
+ const source = sourceMap.get(instance.id);
+ const sourceRepo = source?.sourceRepoId ? { id: source.sourceRepoId, name: source.sourceRepoName ?? null } : null;
+
+ if (isSeriesInstrument(instance)) {
+ const info: SeriesInstrumentInfo = {
+ ...base,
+ kind: 'SERIES',
+ seriesItems: getSeriesInstrumentItems(instance.content)
+ .map(({ edition, name }) => scalarInstrumentIds.get(`${name}:${edition}`))
+ .filter((id): id is string => typeof id === 'string')
+ .map((id) => ({ id })),
+ sourceRepo
};
- }
- if (!info.internal) {
results.set(info.id, info);
continue;
}
+
+ const info: ScalarInstrumentInfo = {
+ ...base,
+ internal: instance.internal,
+ kind: instance.kind,
+ sourceRepo
+ };
+ // Collapse editions of the same instrument to the newest one, keyed by its stable internal name.
const currentEntry = results.get(info.internal.name);
- if (!currentEntry || info.internal.edition > currentEntry.internal!.edition) {
+ if (!currentEntry || !('internal' in currentEntry) || info.internal.edition > currentEntry.internal.edition) {
results.set(info.internal.name, info);
}
}
@@ -226,9 +319,10 @@ export class InstrumentsService {
generateSeriesInstrumentId(instrument: SeriesInstrument) {
return this.cryptoService.hash(
- getSeriesInstrumentItems(instrument.content)
- .map(({ edition, name }) => `${name}-${edition}`)
- .join('--')
+ JSON.stringify({
+ content: instrument.content,
+ title: instrument.details.title
+ })
);
}
@@ -281,6 +375,82 @@ export class InstrumentsService {
return map;
}
+ /** The localized `tags` for a series, matching the shape (unilingual vs multilingual) of `language`. */
+ private buildSeriesTags(language: InstrumentLanguage): InstrumentUIOption {
+ if (typeof language === 'string') {
+ return [seriesTag(language)];
+ }
+ return Object.fromEntries(language.map((lang) => [lang, [seriesTag(lang)]])) as InstrumentUIOption<
+ InstrumentLanguage,
+ string[]
+ >;
+ }
+
+ /**
+ * The title of an existing series instrument that contains the exact same set of items as `items`
+ * (regardless of order), or `null` when none exists. Used to warn before creating a redundant series.
+ * The title is returned in its stored (possibly multilingual) form so the client can localize it.
+ */
+ private async findSeriesTitleWithSameItems(
+ items: ScalarInstrumentInternal[],
+ { ability }: EntityOperationOptions = {}
+ ): Promise | null> {
+ const targetKey = this.getSeriesItemSetKey(items);
+ const seriesInstances = await this.find({ kind: 'SERIES' }, { ability });
+ for (const series of seriesInstances) {
+ if (!isSeriesInstrument(series)) {
+ continue;
+ }
+ if (this.getSeriesItemSetKey(getSeriesInstrumentItems(series.content)) === targetKey) {
+ return series.details.title;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Generate the TypeScript source for an on-the-fly series instrument. The definition is plain
+ * JSON-serializable data, so it is emitted as a single default export with no runtime imports. The
+ * multilingual details/instructions and the language(s) are taken verbatim from the caller — nothing
+ * is hardcoded — so the series is stored exactly as it was authored.
+ */
+ private generateSeriesInstrumentSource({
+ clientDetails,
+ details,
+ items,
+ language
+ }: Pick<$CreateSeriesInstrumentData, 'clientDetails' | 'details' | 'items' | 'language'>): string {
+ // A series is a `SeriesInstrument` minus the server-computed `id`; typing it explicitly keeps this
+ // in sync with the schema so a change to the instrument shape fails to compile here.
+ const definition: Omit = {
+ __runtimeVersion: 1,
+ ...(clientDetails?.instructions ? { clientDetails: { instructions: clientDetails.instructions } } : {}),
+ content: {
+ items: items.map(({ edition, name }) => ({ edition, name }))
+ },
+ details: {
+ description: details.description ?? details.title,
+ license: 'UNLICENSED',
+ title: details.title
+ },
+ kind: 'SERIES',
+ language,
+ tags: this.buildSeriesTags(language)
+ };
+ return `export default ${JSON.stringify(definition)};`;
+ }
+
+ /**
+ * A stable, order-independent key identifying the set of scalar instruments in a series. Two series
+ * with the same items produce the same key even if the items were added in a different order.
+ */
+ private getSeriesItemSetKey(items: ScalarInstrumentInternal[]): string {
+ return items
+ .map(({ edition, name }) => `${name}-${edition}`)
+ .sort()
+ .join('--');
+ }
+
private async instantiate(instruments: Pick[]) {
return Promise.all(
instruments.map((instrument) => {
@@ -289,6 +459,26 @@ export class InstrumentsService {
);
}
+ /**
+ * Whether any instrument on the platform already uses the given title (case-insensitive, across every
+ * language it is defined in). Used to enforce that newly-created series have a unique, recognizable
+ * name. Deliberately unscoped by ability: uniqueness must hold globally, not just for instruments the
+ * caller happens to see.
+ */
+ private async isInstrumentTitleTaken(title: NonNullable): Promise {
+ const normalize = (value: string) => value.trim().toLowerCase();
+ const candidates = new Set(this.titleValues(title).map(normalize));
+ const instances = await this.find({});
+ return instances.some((instance) =>
+ this.titleValues(instance.details.title).some((value) => candidates.has(normalize(value)))
+ );
+ }
+
+ /** The individual language values of a (possibly multilingual) title. */
+ private titleValues(title: InstrumentUIOption): string[] {
+ return typeof title === 'string' ? [title] : Object.values(title);
+ }
+
private async validateSeriesInstrument(instrument: SeriesInstrument) {
const items = getSeriesInstrumentItems(instrument.content);
if (items.length < 2) {
diff --git a/apps/web/src/components/InstrumentCard/InstrumentCard.tsx b/apps/web/src/components/InstrumentCard/InstrumentCard.tsx
index d2cadd379..cf865f71a 100644
--- a/apps/web/src/components/InstrumentCard/InstrumentCard.tsx
+++ b/apps/web/src/components/InstrumentCard/InstrumentCard.tsx
@@ -48,7 +48,7 @@ export const InstrumentCard = ({ instrument, onClick }: InstrumentCardProps) =>
en: 'Edition',
fr: 'Édition'
}),
- text: instrument.internal?.edition.toString()
+ text: instrument.kind === 'SERIES' ? undefined : instrument.internal.edition.toString()
},
{
kind: 'text',
diff --git a/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.stories.tsx b/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.stories.tsx
index 6e8a19f6c..f633e4eb1 100644
--- a/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.stories.tsx
+++ b/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.stories.tsx
@@ -13,7 +13,9 @@ export default { component: InstrumentShowcase } as Meta
- translateInstrumentInfo(instance, 'en')
+ // The stubs are full instruments; `translateInstrumentInfo` only reads the fields common to every
+ // `InstrumentInfo`, so a cast to its parameter type is sufficient for the story.
+ translateInstrumentInfo(instance as unknown as Parameters[0], 'en')
)
}
};
diff --git a/apps/web/src/hooks/useCreateSeriesInstrumentMutation.ts b/apps/web/src/hooks/useCreateSeriesInstrumentMutation.ts
new file mode 100644
index 000000000..3f16e653b
--- /dev/null
+++ b/apps/web/src/hooks/useCreateSeriesInstrumentMutation.ts
@@ -0,0 +1,41 @@
+import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import type { $CreateSeriesInstrumentData, CreateSeriesInstrumentResult } from '@opendatacapture/schemas/instrument';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import axios from 'axios';
+
+import { getApiErrorMessage } from '@/utils/error';
+
+// The request/response contract is shared with the server (`@opendatacapture/schemas/instrument`) so the
+// two cannot drift: `$CreateSeriesInstrumentData` is the request body and `CreateSeriesInstrumentResult`
+// is a discriminated union over `outcome` (either the series was created, or the caller must confirm a
+// duplicate before it is).
+export function useCreateSeriesInstrumentMutation() {
+ const queryClient = useQueryClient();
+ const addNotification = useNotificationsStore((store) => store.addNotification);
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: async (data: $CreateSeriesInstrumentData) => {
+ const response = await axios.post('/v1/instruments/series', data, {
+ meta: { disableDefaultErrorNotification: true }
+ });
+ return response.data;
+ },
+ onError(err) {
+ addNotification({
+ message: getApiErrorMessage(
+ err,
+ t({ en: 'Failed to create series instrument', fr: "Échec de la création de l'instrument en série" })
+ ),
+ type: 'error'
+ });
+ },
+ onSuccess(data) {
+ // A duplicate prompt is not a creation: do not announce success or refresh the list yet.
+ if (data.outcome === 'duplicate') {
+ return;
+ }
+ addNotification({ type: 'success' });
+ void queryClient.invalidateQueries({ queryKey: ['instrument-info'] });
+ }
+ });
+}
diff --git a/apps/web/src/hooks/useDeleteInstrumentMutation.ts b/apps/web/src/hooks/useDeleteInstrumentMutation.ts
new file mode 100644
index 000000000..ca1099a84
--- /dev/null
+++ b/apps/web/src/hooks/useDeleteInstrumentMutation.ts
@@ -0,0 +1,30 @@
+import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import axios from 'axios';
+
+import { getApiErrorMessage } from '@/utils/error';
+
+export function useDeleteInstrumentMutation() {
+ const queryClient = useQueryClient();
+ const addNotification = useNotificationsStore((store) => store.addNotification);
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: ({ id }: { id: string }) =>
+ axios.delete(`/v1/instruments/${id}`, { meta: { disableDefaultErrorNotification: true } }),
+ onError(err) {
+ addNotification({
+ message: getApiErrorMessage(
+ err,
+ t({ en: 'Failed to delete instrument', fr: "Échec de la suppression de l'instrument" })
+ ),
+ type: 'error'
+ });
+ },
+ onSuccess() {
+ addNotification({ type: 'success' });
+ // The server has already detached the instrument from every group; callers that hold a stale copy
+ // of the group's accessible ids (e.g. the manage page) reconcile it locally via the mutation result.
+ void queryClient.invalidateQueries({ queryKey: ['instrument-info'] });
+ }
+ });
+}
diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx
index 5e2e8644b..6303d6376 100644
--- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx
+++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx
@@ -317,7 +317,9 @@ const RouteComponent = () => {
{instrument.details.title}
{instrument.kind}
- {instrument.internal?.edition ?? '-'}
+
+ {instrument.kind === 'SERIES' ? '-' : instrument.internal.edition}
+
))}
diff --git a/apps/web/src/routes/_app/group/manage.tsx b/apps/web/src/routes/_app/group/manage.tsx
index 543894a0b..6161ed89b 100644
--- a/apps/web/src/routes/_app/group/manage.tsx
+++ b/apps/web/src/routes/_app/group/manage.tsx
@@ -7,22 +7,28 @@ import {
Dialog,
Form,
Heading,
+ Input,
SearchBar,
- Spinner
+ Spinner,
+ TextArea
} from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { InstrumentRenderer } from '@opendatacapture/react-core';
+import type { ScalarInstrumentInternal } from '@opendatacapture/runtime-core';
import { $RegexString } from '@opendatacapture/schemas/core';
import type { UpdateGroupData } from '@opendatacapture/schemas/group';
+import type { $CreateSeriesInstrumentData, InstrumentBundleContainer } from '@opendatacapture/schemas/instrument';
import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject';
import type { SubjectIdentificationMethod } from '@opendatacapture/schemas/subject';
import { createFileRoute } from '@tanstack/react-router';
-import { EyeIcon } from 'lucide-react';
+import { EyeIcon, TrashIcon } from 'lucide-react';
import type { Promisable } from 'type-fest';
import { z } from 'zod/v4';
import { PageHeader } from '@/components/PageHeader';
import { WithFallback } from '@/components/WithFallback';
+import { useCreateSeriesInstrumentMutation } from '@/hooks/useCreateSeriesInstrumentMutation';
+import { useDeleteInstrumentMutation } from '@/hooks/useDeleteInstrumentMutation';
import { useInstrumentBundle } from '@/hooks/useInstrumentBundle';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
@@ -35,7 +41,10 @@ type InstrumentItem = {
authors?: null | string[];
description?: string;
id: string;
+ // The scalar instrument identity (name + edition); null for series instruments, which have no edition.
+ internal: null | ScalarInstrumentInternal;
kind: string;
+ seriesItems?: { id: string }[];
source: InstrumentSource;
title: string;
};
@@ -46,6 +55,36 @@ type CategorizedInstruments = {
series: InstrumentItem[];
};
+const getSeriesPreviewItemTitles = ({
+ bundle,
+ fallbackTitle,
+ items
+}: {
+ bundle?: InstrumentBundleContainer;
+ fallbackTitle: (index: number) => string;
+ items: InstrumentItem[];
+}) => {
+ if (bundle?.kind !== 'SERIES') {
+ return [];
+ }
+ return bundle.items.map((seriesItem, index) => {
+ return items.find((item) => item.id === seriesItem.id)?.title ?? fallbackTitle(index);
+ });
+};
+
+const expandSelectedSeriesIds = ({ selectedIds, series }: { selectedIds: Set; series: InstrumentItem[] }) => {
+ const expandedIds = new Set(selectedIds);
+ for (const item of series) {
+ if (!selectedIds.has(item.id)) {
+ continue;
+ }
+ for (const seriesItem of item.seriesItems ?? []) {
+ expandedIds.add(seriesItem.id);
+ }
+ }
+ return expandedIds;
+};
+
type SettingsValues = {
defaultIdentificationMethod?: SubjectIdentificationMethod;
idValidationRegex?: null | string;
@@ -58,6 +97,8 @@ type SettingsValues = {
type ManageGroupFormProps = {
data: {
+ // The titles of every visible instrument, used to enforce unique new series instrument names.
+ existingTitles: string[];
// Accessible instrument ids that are not in the visible list; preserved as-is on save.
hiddenAccessibleIds: string[];
initialSelectedIds: string[];
@@ -70,6 +111,7 @@ type ManageGroupFormProps = {
const InstrumentSection = ({
items,
+ onDelete,
onPreview,
onToggle,
readOnly,
@@ -78,6 +120,8 @@ const InstrumentSection = ({
title
}: {
items: InstrumentItem[];
+ // When provided, a delete affordance is shown for items where it returns true.
+ onDelete?: (item: InstrumentItem) => void;
onPreview: (item: InstrumentItem) => void;
onToggle: (id: string) => void;
readOnly: boolean;
@@ -94,7 +138,7 @@ const InstrumentSection = ({
return (
-
{title}
+ {title &&
{title}
}
{filtered.length === 0 ? (
{t({ en: 'No instruments available.', fr: 'Aucun instrument disponible.' })}
@@ -111,25 +155,44 @@ const InstrumentSection = ({
disabled={readOnly}
onCheckedChange={() => onToggle(item.id)}
/>
-
- {item.title}
-
-
- {item.source.kind === 'repo'
- ? item.source.name
- : t({ en: 'Uploaded manually', fr: 'Téléversé manuellement' })}
-
+
+ {item.source.kind === 'repo' ? item.source.name : t({ en: 'No repo', fr: 'Aucun dépôt' })}
+
+
+
+ {onDelete && !readOnly && item.source.kind === 'manual' && (
+
+ )}
+
))}
@@ -138,10 +201,27 @@ const InstrumentSection = ({
);
};
-const InstrumentPreviewDialog = ({ item, onClose }: { item: InstrumentItem; onClose: () => void }) => {
+const InstrumentPreviewDialog = ({
+ item,
+ items,
+ onClose
+}: {
+ item: InstrumentItem;
+ items: InstrumentItem[];
+ onClose: () => void;
+}) => {
const { t } = useTranslation();
const [showForm, setShowForm] = useState(false);
- const bundleQuery = useInstrumentBundle(showForm ? item.id : null);
+ const bundleQuery = useInstrumentBundle(showForm || item.kind === 'SERIES' ? item.id : null);
+ // Passed to the renderer as a localizable value; the shared component resolves it to the active language.
+ const previewSubmitLabel = { en: 'Preview Submit', fr: 'Soumettre l’aperçu' };
+ const seriesItemTitles = useMemo(() => {
+ return getSeriesPreviewItemTitles({
+ bundle: bundleQuery.data,
+ fallbackTitle: (index) => t({ en: `Item ${index + 1}`, fr: `Élément ${index + 1}` }),
+ items
+ });
+ }, [bundleQuery.data, items, t]);
return (