From d7147b071f90bd0cf80a8ebe36506ddc1151439d Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 30 Sep 2026 12:42:26 -0400 Subject: [PATCH 1/3] feat: add an admin instruments page, and let administrators archive series A series that has been administered can never be deleted, so there was no way to retire one. The Admin Panel gains an Instruments submenu with two views of /admin/instruments: - Form & Interactive lists every instrument at its current edition, with its kind, source, the groups using it and when it was added; double-clicking a row previews it. - Series lists every series with the group that owns it (or all groups), and lets an administrator preview, archive or unarchive it. Previews use the same dialog as the group manage page. An archived series is hidden from the start-session, remote assignment and group manage pickers, and the API refuses new assignments of it; collected records and outstanding assignments are untouched. Archive and unarchive are recorded in the audit log. Co-Authored-By: Claude Opus 5.5 --- .../docs/architecture/auth-and-permissions.md | 31 +- apps/api/prisma/schema.prisma | 4 + .../__tests__/assignments.service.spec.ts | 19 + .../src/assignments/assignments.service.ts | 26 +- .../__tests__/instruments.controller.spec.ts | 43 ++ .../__tests__/instruments.service.spec.ts | 177 +++++++- .../src/instruments/instruments.controller.ts | 32 +- .../src/instruments/instruments.service.ts | 251 +++++++---- .../InstrumentPreviewDialog.tsx | 202 +++++++++ .../InstrumentPreviewDialog/index.ts | 1 + .../useSeriesInstrumentsOverviewQuery.test.ts | 45 ++ apps/web/src/hooks/useNavItems.ts | 25 ++ .../useSeriesInstrumentsOverviewQuery.ts | 19 + ...seUpdateSeriesInstrumentArchiveMutation.ts | 40 ++ apps/web/src/route-tree.ts | 21 + apps/web/src/routes/_app/admin/audit/logs.tsx | 2 +- .../web/src/routes/_app/admin/instruments.tsx | 404 ++++++++++++++++++ apps/web/src/routes/_app/group/manage.tsx | 200 +-------- .../routes/_app/group/remote-assignments.tsx | 8 +- .../instruments/accessible-instruments.tsx | 7 +- .../routes/_app/session/remote-assignment.tsx | 7 +- apps/web/src/translations/common.json | 10 + .../administrable-instruments.test.ts | 32 ++ .../__tests__/instrument-editions.test.ts | 16 + .../utils/__tests__/series-overview.test.ts | 32 ++ .../src/utils/administrable-instruments.ts | 27 ++ apps/web/src/utils/instrument-editions.ts | 17 + apps/web/src/utils/series-overview.ts | 23 + packages/schemas/src/audit/audit.ts | 2 +- .../schemas/src/instrument/instrument.base.ts | 22 +- .../src/pages/_app/admin/instruments.page.ts | 40 ++ testing/src/specs/admin-instruments.spec.ts | 90 ++++ testing/src/support/api-client.ts | 16 + testing/src/support/fixtures.ts | 2 + 34 files changed, 1582 insertions(+), 311 deletions(-) create mode 100644 apps/web/src/components/InstrumentPreviewDialog/InstrumentPreviewDialog.tsx create mode 100644 apps/web/src/components/InstrumentPreviewDialog/index.ts create mode 100644 apps/web/src/hooks/__tests__/useSeriesInstrumentsOverviewQuery.test.ts create mode 100644 apps/web/src/hooks/useSeriesInstrumentsOverviewQuery.ts create mode 100644 apps/web/src/hooks/useUpdateSeriesInstrumentArchiveMutation.ts create mode 100644 apps/web/src/routes/_app/admin/instruments.tsx create mode 100644 apps/web/src/utils/__tests__/administrable-instruments.test.ts create mode 100644 apps/web/src/utils/__tests__/instrument-editions.test.ts create mode 100644 apps/web/src/utils/__tests__/series-overview.test.ts create mode 100644 apps/web/src/utils/administrable-instruments.ts create mode 100644 apps/web/src/utils/instrument-editions.ts create mode 100644 apps/web/src/utils/series-overview.ts create mode 100644 testing/src/pages/_app/admin/instruments.page.ts create mode 100644 testing/src/specs/admin-instruments.spec.ts diff --git a/.agents/docs/architecture/auth-and-permissions.md b/.agents/docs/architecture/auth-and-permissions.md index bf564d168..563916dec 100644 --- a/.agents/docs/architecture/auth-and-permissions.md +++ b/.agents/docs/architecture/auth-and-permissions.md @@ -27,21 +27,22 @@ Routes are URI-versioned (`version: '1'` in `src/main.ts`), so paths are `/v1/.. The full inventory of non-ordinary access declarations, current as of writing: -| Route | Declaration | Why it is safe | -| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `POST /v1/auth/login` | `'public'` | `@ThrottleLoginRequest()`; credentials checked in `AuthService.login`. | -| `GET /v1/setup` | `'public'` | Returns only `SetupState` (branding, flags, release, uptime). | -| `POST /v1/setup` | `'public'` | **`SetupService.initApp` drops the whole database.** Its only protection is `if (savedOptions?.isSetup && !isDev) throw new ForbiddenException()` — an initialised production instance refuses, a development instance never does. | -| `DELETE /v1/setup` | `{ action: 'delete', subject: 'all' }` | Also refuses unless `NODE_ENV === 'test'`. | -| `PATCH /v1/setup` | `ADMIN_ONLY` | Only `ADMIN` gets `manage all`. | -| `GET /v1/audit/logs` | `ADMIN_ONLY` | `AuditService.find` is deliberately unscoped; the guard is the whole check. | -| `GET /v1/gateway/healthcheck` | `[]` | Any login token; an instrument token is refused. Module only loads when `GATEWAY_ENABLED`. | -| `POST /v1/groups` | `ADMIN_ONLY` | `ADMIN` alone. `create Group` admitted every `GROUP_MANAGER`: their `manage Group` rule is conditioned on their own groups, and this check sees only the subject type (#1468). | -| `POST /v1/instruments` | `{ action: 'manage', subject: 'Instrument' }` | No base permission level grants `manage Instrument`, so this is `ADMIN`-only in practice. Also `@AcceptsInstrumentToken()`: the only route the playground's minted token reaches. | -| `PATCH /v1/users/self-update/:id` | `{ action: 'read', subject: 'User' }` | Deliberately weak; `UsersService.updateSelfById` throws `ForbiddenException` unless `id === currentUser.id`. The controller carries a comment saying so. | -| `PUT /v1/users/:id/permissions` | `ADMIN_ONLY` | `ADMIN` alone. An `update User` grant is one of the things this route hands out, so it must not be enough to reach it, or the holder could grant themselves `manage all`. `$UpdateUserData` no longer carries the field either. | -| `POST /v1/users`, `PATCH /v1/users/:id`, `DELETE /v1/users/:id` | `ADMIN_ONLY` | `ADMIN` alone, whatever `User` action a grant names. Level, groups and password are what the rest of a user's access derives from, so a grantee could otherwise promote themselves, join every group, or log in as an admin whose password they set. `UsersService` also refuses an admin deleting, disabling or demoting their own account, so the last one cannot lock every admin-only route. | -| `GET /v1/summary` | five-element array (`read` on `Instrument`, `InstrumentRecord`, `Session`, `Subject`, `User`) | The only use of the multi-element array form; all five must pass. | +| Route | Declaration | Why it is safe | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `POST /v1/auth/login` | `'public'` | `@ThrottleLoginRequest()`; credentials checked in `AuthService.login`. | +| `GET /v1/setup` | `'public'` | Returns only `SetupState` (branding, flags, release, uptime). | +| `POST /v1/setup` | `'public'` | **`SetupService.initApp` drops the whole database.** Its only protection is `if (savedOptions?.isSetup && !isDev) throw new ForbiddenException()` — an initialised production instance refuses, a development instance never does. | +| `DELETE /v1/setup` | `{ action: 'delete', subject: 'all' }` | Also refuses unless `NODE_ENV === 'test'`. | +| `PATCH /v1/setup` | `ADMIN_ONLY` | Only `ADMIN` gets `manage all`. | +| `GET /v1/audit/logs` | `ADMIN_ONLY` | `AuditService.find` is deliberately unscoped; the guard is the whole check. | +| `GET /v1/gateway/healthcheck` | `[]` | Any login token; an instrument token is refused. Module only loads when `GATEWAY_ENABLED`. | +| `POST /v1/groups` | `ADMIN_ONLY` | `ADMIN` alone. `create Group` admitted every `GROUP_MANAGER`: their `manage Group` rule is conditioned on their own groups, and this check sees only the subject type (#1468). | +| `POST /v1/instruments` | `{ action: 'manage', subject: 'Instrument' }` | No base permission level grants `manage Instrument`, so this is `ADMIN`-only in practice. Also `@AcceptsInstrumentToken()`: the only route the playground's minted token reaches. | +| `GET /v1/instruments/series`, `PATCH /v1/instruments/series/:id` | `ADMIN_ONLY` | `ADMIN` alone. `InstrumentsService.findSeriesOverview` lists every group's series, deliberately not narrowed to the caller's groups (an administrator belongs to none), so the guard is what keeps it from a `GROUP_MANAGER`, who holds `read Instrument`. Archiving is likewise administrators' alone; group managers keep deleting their own unused series. | +| `PATCH /v1/users/self-update/:id` | `{ action: 'read', subject: 'User' }` | Deliberately weak; `UsersService.updateSelfById` throws `ForbiddenException` unless `id === currentUser.id`. The controller carries a comment saying so. | +| `PUT /v1/users/:id/permissions` | `ADMIN_ONLY` | `ADMIN` alone. An `update User` grant is one of the things this route hands out, so it must not be enough to reach it, or the holder could grant themselves `manage all`. `$UpdateUserData` no longer carries the field either. | +| `POST /v1/users`, `PATCH /v1/users/:id`, `DELETE /v1/users/:id` | `ADMIN_ONLY` | `ADMIN` alone, whatever `User` action a grant names. Level, groups and password are what the rest of a user's access derives from, so a grantee could otherwise promote themselves, join every group, or log in as an admin whose password they set. `UsersService` also refuses an admin deleting, disabling or demoting their own account, so the last one cannot lock every admin-only route. | +| `GET /v1/summary` | five-element array (`read` on `Instrument`, `InstrumentRecord`, `Session`, `Subject`, `User`) | The only use of the multi-element array form; all five must pass. | Adding a fourth `'public'` route, or a second `[]`, is a security decision — raise it rather than deciding alone. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a933f8072..299e45a58 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -33,6 +33,8 @@ enum AuditLogAction { DELETE LOGIN SEND_EMAIL + ARCHIVE + UNARCHIVE } enum AuditLogEntity { @@ -223,6 +225,8 @@ model Instrument { createdAt DateTime @default(now()) @db.Date updatedAt DateTime @updatedAt @db.Date id String @id @map("_id") + // Set when an administrator retires a series from new sessions and assignments; null while active. + archivedAt DateTime? @db.Date assignments Assignment[] bundle String groups Group[] @relation(fields: [groupIds], references: [id]) diff --git a/apps/api/src/assignments/__tests__/assignments.service.spec.ts b/apps/api/src/assignments/__tests__/assignments.service.spec.ts index e5602001c..4c235dc68 100644 --- a/apps/api/src/assignments/__tests__/assignments.service.spec.ts +++ b/apps/api/src/assignments/__tests__/assignments.service.spec.ts @@ -76,6 +76,7 @@ describe('AssignmentsService', () => { let assignmentsService: AssignmentsService; let assignmentModel: MockedInstance>; let groupModel: MockedInstance>; + let instrumentModel: MockedInstance>; let subjectModel: MockedInstance>; let auditLogger: MockedInstance; let gatewayService: MockedInstance; @@ -86,6 +87,7 @@ describe('AssignmentsService', () => { AssignmentsService, MockFactory.createForModelToken(getModelToken('Assignment')), MockFactory.createForModelToken(getModelToken('Group')), + MockFactory.createForModelToken(getModelToken('Instrument')), MockFactory.createForModelToken(getModelToken('Subject')), { provide: AuditLogger, useValue: { log: vi.fn() } }, { provide: ConfigService, useValue: { get: () => 3500, getOrThrow: () => ({ origin: 'https://x' }) } }, @@ -103,6 +105,7 @@ describe('AssignmentsService', () => { assignmentModel = moduleRef.get(getModelToken('Assignment')); groupModel = moduleRef.get(getModelToken('Group')); + instrumentModel = moduleRef.get(getModelToken('Instrument')); subjectModel = moduleRef.get(getModelToken('Subject')); auditLogger = moduleRef.get(AuditLogger); gatewayService = moduleRef.get(GatewayService); @@ -111,6 +114,7 @@ describe('AssignmentsService', () => { groupModel.findFirst.mockResolvedValue({ accessibleInstrumentIds: ['instrument-1', 'instrument-2'], id: GROUP_ID }); subjectModel.findMany.mockResolvedValue([{ id: 'subject-1' }, { id: 'subject-2' }]); assignmentModel.findMany.mockResolvedValue([]); + instrumentModel.findMany.mockResolvedValue([]); assignmentModel.create.mockImplementation(({ data }: any) => Promise.resolve({ ...data, instrumentId: 'instrument-1' }) ); @@ -155,6 +159,14 @@ describe('AssignmentsService', () => { expect(failure.issues).toContainEqual({ instrumentIds: ['instrument-other'], kind: 'INSTRUMENT_UNAVAILABLE' }); }); + // An archived series stays on the group's opt-in list so unarchiving restores it, so that list + // alone would still admit it. + it('should refuse an archived series the group has opted into, since archiving retires it from new assignments', async () => { + instrumentModel.findMany.mockResolvedValueOnce([{ id: 'instrument-1' }]); + const failure = await failureOf(assignmentsService.bulkPreflight(request(), permissiveUser())); + expect(failure.issues).toContainEqual({ instrumentIds: ['instrument-1'], kind: 'INSTRUMENT_UNAVAILABLE' }); + }); + it('should restrict subjects to the selected group and the caller ability', async () => { await assignmentsService.bulkPreflight(request(), permissiveUser()); expect(subjectModel.findMany.mock.lastCall?.[0]).toMatchObject({ @@ -269,6 +281,13 @@ describe('AssignmentsService', () => { expect(gatewayService.createRemoteAssignment).toHaveBeenCalledTimes(1); }); + it('should refuse an administrator an ungrouped assignment of an archived series, which skips the group checks', async () => { + instrumentModel.findMany.mockResolvedValueOnce([{ id: 'instrument-1' }]); + const failure = await failureOf(assignmentsService.create({ ...data(), groupId: undefined }, userAt('ADMIN'))); + expect(failure.issues).toContainEqual({ instrumentIds: ['instrument-1'], kind: 'INSTRUMENT_UNAVAILABLE' }); + expect(assignmentModel.create).not.toHaveBeenCalled(); + }); + it('should file a grouped assignment under the group it names, so that group can find and cancel it', async () => { await assignmentsService.create(data(), userAt('GROUP_MANAGER')); expect(assignmentModel.create.mock.lastCall?.[0].data.group).toStrictEqual({ connect: { id: GROUP_ID } }); diff --git a/apps/api/src/assignments/assignments.service.ts b/apps/api/src/assignments/assignments.service.ts index f375acbca..e730b1449 100644 --- a/apps/api/src/assignments/assignments.service.ts +++ b/apps/api/src/assignments/assignments.service.ts @@ -43,6 +43,7 @@ export class AssignmentsService { constructor( @InjectModel('Assignment') private readonly assignmentModel: Model<'Assignment'>, @InjectModel('Group') private readonly groupModel: Model<'Group'>, + @InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'>, @InjectModel('Subject') private readonly subjectModel: Model<'Subject'>, configService: ConfigService, private readonly auditLogger: AuditLogger, @@ -84,6 +85,11 @@ export class AssignmentsService { ); } else if (!currentUser.ability.can('create', forcedAppSubject('Assignment', { groupId: null }))) { throw new ForbiddenException('Insufficient permissions to create an assignment outside a group'); + } else if ((await this.findArchivedInstrumentIds([instrumentId])).length > 0) { + throw new UnprocessableEntityException({ + code: 'BULK_ASSIGNMENT_REFUSED', + issues: [{ instrumentIds: [instrumentId], kind: 'INSTRUMENT_UNAVAILABLE' }] + } satisfies BulkAssignmentFailure); } const { assignment, publicKey } = await this.stageAssignment({ expiresAt, groupId, instrumentId, subjectId }); try { @@ -231,6 +237,18 @@ export class AssignmentsService { } } + /** + * Which of the given instruments an administrator has archived. Unscoped by the caller's ability: + * it only narrows ids the caller already named, and every other check on them is made separately. + */ + private async findArchivedInstrumentIds(instrumentIds: string[]): Promise { + const archived = await this.instrumentModel.findMany({ + select: { id: true }, + where: { archivedAt: { not: null }, id: { in: instrumentIds } } + }); + return archived.map(({ id }) => id); + } + /** * Every authorization and validity check a grouped assignment depends on, in one place so * preflight, bulk create and single create cannot drift apart. Throws with all issues attached; @@ -254,10 +272,14 @@ export class AssignmentsService { const issues: BulkAssignmentIssue[] = []; // The group's own opt-in list is the authority: an instrument existing is not permission to - // assign it here. + // assign it here. An archived series stays on that list, so it can be unarchived without every + // group opting back in, and is refused separately. const accessibleInstrumentIds = new Set(group.accessibleInstrumentIds); const instrumentIds = timepoints.map(({ instrumentId }) => instrumentId); - const unavailableInstrumentIds = instrumentIds.filter((id) => !accessibleInstrumentIds.has(id)); + const archivedInstrumentIds = new Set(await this.findArchivedInstrumentIds(instrumentIds)); + const unavailableInstrumentIds = [ + ...new Set(instrumentIds.filter((id) => !accessibleInstrumentIds.has(id) || archivedInstrumentIds.has(id))) + ]; if (unavailableInstrumentIds.length > 0) { issues.push({ instrumentIds: unavailableInstrumentIds, kind: 'INSTRUMENT_UNAVAILABLE' }); } diff --git a/apps/api/src/instruments/__tests__/instruments.controller.spec.ts b/apps/api/src/instruments/__tests__/instruments.controller.spec.ts index 173df0ab0..b8b4ddca5 100644 --- a/apps/api/src/instruments/__tests__/instruments.controller.spec.ts +++ b/apps/api/src/instruments/__tests__/instruments.controller.spec.ts @@ -1,10 +1,16 @@ +import { LoggingService } from '@douglasneuroinformatics/libnest'; import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; import { Reflector } from '@nestjs/core'; import { Test } from '@nestjs/testing'; +import type { Group } from '@opendatacapture/schemas/group'; +import type { BasePermissionLevel } from '@opendatacapture/schemas/user'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AbilityFactory } from '@/auth/ability.factory'; import { ACCEPTS_INSTRUMENT_TOKEN_METADATA_KEY } from '@/core/decorators/accepts-instrument-token.decorator'; +import { ROUTE_ACCESS_METADATA_KEY } from '@/core/decorators/route-access.decorator'; +import type { ProtectedRoutePermissionSet } from '@/core/decorators/route-access.decorator'; import { InstrumentsController } from '../instruments.controller'; import { InstrumentsService } from '../instruments.service'; @@ -53,4 +59,41 @@ describe('InstrumentsController', () => { ); expect(accepting).toEqual(['create']); }); + + // Every group's series, and retiring one, belong to administrators alone: a group manager may create + // and delete their own group's series, which the guard would not tell apart from any other. + describe.each(['findSeriesOverview', 'updateSeriesArchive'] as const)('route access for %s', (handlerName) => { + const abilityFor = (basePermissionLevel: BasePermissionLevel) => + new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload({ + basePermissionLevel, + firstName: 'Test', + groups: [{ id: 'group-1' }] as Group[], + id: 'user-1', + kind: 'login', + lastName: 'User', + mustResetPassword: false, + username: 'test-user' + }); + const { action, subject } = new Reflector().get( + ROUTE_ACCESS_METADATA_KEY, + Object.getOwnPropertyDescriptor(InstrumentsController.prototype, handlerName)!.value + ); + + it('should refuse a group manager, who may otherwise create and delete their own series', () => { + expect(abilityFor('GROUP_MANAGER').can(action, subject)).toBe(false); + }); + + it('should allow an administrator', () => { + expect(abilityFor('ADMIN').can(action, subject)).toBe(true); + }); + }); + + it('should hand the caller ability to the series overview, so the group lookup stays scoped', async () => { + const ability = { can: vi.fn(() => true) } as any; + instrumentsService.findSeriesOverview.mockResolvedValue([]); + + await instrumentsController.findSeriesOverview(ability); + + expect(instrumentsService.findSeriesOverview).toHaveBeenCalledWith({ ability }); + }); }); diff --git a/apps/api/src/instruments/__tests__/instruments.service.spec.ts b/apps/api/src/instruments/__tests__/instruments.service.spec.ts index aeb31f2a4..65d2fe21f 100644 --- a/apps/api/src/instruments/__tests__/instruments.service.spec.ts +++ b/apps/api/src/instruments/__tests__/instruments.service.spec.ts @@ -8,7 +8,9 @@ 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 type { MockInstance } from 'vitest'; +import { AuditLogger } from '@/audit/audit.logger'; import { AbilityFactory } from '@/auth/ability.factory'; import { accessibleQuery, createAppAbility } from '@/auth/ability.utils'; @@ -65,6 +67,7 @@ describe('InstrumentsService', () => { let instrumentModel: MockedInstance>; let instrumentRecordModel: MockedInstance>; let groupModel: MockedInstance>; + let auditLogger: MockedInstance; let virtualizationService: MockedInstance>; /** The same Map the service memoizes evaluated instances into — see the context assignment below. */ let instanceCache: InstrumentVirtualizationContext['instruments']; @@ -79,6 +82,7 @@ describe('InstrumentsService', () => { MockFactory.createForModelToken(getModelToken('Group')), MockFactory.createForModelToken(getModelToken('Instrument')), MockFactory.createForModelToken(getModelToken('InstrumentRecord')), + MockFactory.createForService(AuditLogger), MockFactory.createForService(CryptoService), MockFactory.createForService(LoggingService), MockFactory.createForService(VirtualizationService) @@ -91,6 +95,7 @@ describe('InstrumentsService', () => { instrumentModel = moduleRef.get(getModelToken('Instrument')); instrumentRecordModel = moduleRef.get(getModelToken('InstrumentRecord')); groupModel = moduleRef.get(getModelToken('Group')); + auditLogger = moduleRef.get(AuditLogger); // Two lookups hit this: the caller's permission check on the target group, and the item-access // check in `validateSeriesInstrument`. The empty arrays mean "no repos assigned, nothing accessible // yet", so by default only non-repo instruments may be assembled into a series. @@ -717,7 +722,14 @@ describe('InstrumentsService', () => { const result = await instrumentsService.findInfo(); expect(instrumentModel.findMany).toHaveBeenCalledWith({ - select: { createdAt: true, id: true, seriesGroupId: true, sourceRepoId: true, sourceRepoName: true }, + select: { + archivedAt: true, + createdAt: true, + id: true, + seriesGroupId: true, + sourceRepoId: true, + sourceRepoName: true + }, where: { id: { in: ['owned', 'shared'] } } }); expect(result).toMatchObject([ @@ -751,6 +763,26 @@ describe('InstrumentsService', () => { expect(result).toMatchObject([{ createdAt, id: 'id-2' }]); }); + // Pickers drop an archived series, so it must reach them; records collected with it still need its info. + it('should report when each series was archived, and null for one still active', async () => { + const archivedAt = new Date('2024-06-01T00:00:00.000Z'); + vi.spyOn(instrumentsService, 'find').mockResolvedValue([ + { ...existingSeries, content: { items: [] }, id: 'archived' }, + { ...existingSeries, content: { items: [] }, id: 'active' } + ]); + instrumentModel.findMany.mockResolvedValue([ + { archivedAt, id: 'archived', seriesGroupId: null, sourceRepoId: null, sourceRepoName: null }, + { archivedAt: null, id: 'active', seriesGroupId: null, sourceRepoId: null, sourceRepoName: null } + ]); + + const result = await instrumentsService.findInfo(); + + expect(result).toMatchObject([ + { archivedAt, id: 'archived' }, + { archivedAt: null, id: 'active' } + ]); + }); + // The stored record is read separately from the evaluated instance, so an id present in one and // absent from the other must leave the date empty rather than invent one. it('should report a null creation date for a series with no stored record', async () => { @@ -765,6 +797,149 @@ describe('InstrumentsService', () => { }); }); + describe('findSeriesOverview', () => { + const ability = createAppAbility([{ action: 'manage', subject: 'all' }]); + let find: MockInstance; + + beforeEach(() => { + find = vi.spyOn(instrumentsService, 'find').mockResolvedValue([ + { ...existingSeries, content: { items: [] }, id: 'owned' }, + { ...existingSeries, content: { items: [] }, id: 'shared' } + ]); + instrumentModel.findMany.mockResolvedValue([ + { id: 'owned', seriesGroupId: 'group-1', sourceRepoId: null, sourceRepoName: null }, + { id: 'shared', seriesGroupId: null, sourceRepoId: null, sourceRepoName: null } + ]); + groupModel.findMany.mockResolvedValue([{ id: 'group-1', name: 'Depression Clinic' }]); + }); + + it('should list every group series rather than the caller groups, since an administrator belongs to none', async () => { + await instrumentsService.findSeriesOverview({ ability }); + expect(find).toHaveBeenCalledWith({ kind: 'SERIES' }, { ability }, undefined); + }); + + it('should name the owning group of each series, and none for a series shared by every group', async () => { + const result = await instrumentsService.findSeriesOverview({ ability }); + expect(result).toMatchObject([ + { id: 'owned', seriesGroup: { id: 'group-1', name: 'Depression Clinic' } }, + { id: 'shared', seriesGroup: null } + ]); + }); + + it('should look up only the owning groups, within what the caller may read', async () => { + await instrumentsService.findSeriesOverview({ ability }); + expect(groupModel.findMany).toHaveBeenCalledWith({ + select: { id: true, name: true }, + where: { AND: [accessibleQuery(ability, 'read', 'Group')], id: { in: ['group-1'] } } + }); + }); + }); + + describe('findBundleById', () => { + const ownedSeriesFilter = (groupIds: string[]) => ({ + OR: [{ seriesGroupId: null }, { seriesGroupId: { isSet: false } }, { seriesGroupId: { in: groupIds } }] + }); + + beforeEach(() => { + instrumentModel.findFirst.mockResolvedValue({ bundle: '__BUNDLE__', id: 'form' }); + virtualizationService.eval.mockResolvedValue({ + isErr: () => false, + value: { internal: { edition: 1, name: 'FORM_A' }, kind: 'FORM' } + } as any); + }); + + it('should not narrow an administrator to their groups, so they can preview a series any group owns', async () => { + const ability = createAppAbility([{ action: 'manage', subject: 'all' }]); + await instrumentsService.findBundleById('form', { ability, groups: [] } as any); + expect(instrumentModel.findFirst.mock.lastCall?.[0]).toMatchObject({ + where: { AND: [accessibleQuery(ability, 'read', 'Instrument'), {}] } + }); + }); + + it('should still narrow anyone else to the series their own groups own', async () => { + const ability = createAppAbility([{ action: 'read', subject: 'Instrument' }]); + await instrumentsService.findBundleById('form', { ability, groups: [{ id: 'group-1' }] } as any); + expect(instrumentModel.findFirst.mock.lastCall?.[0]).toMatchObject({ + where: { AND: [accessibleQuery(ability, 'read', 'Instrument'), ownedSeriesFilter(['group-1'])] } + }); + }); + }); + + describe('updateSeriesArchive', () => { + const ability = createAppAbility([{ action: 'manage', subject: 'all' }]); + const currentUser = { ability, id: 'admin-1' } as any; + + const storeSeries = (stored: { archivedAt: Date | null; seriesGroupId: null | string }) => { + instrumentModel.findFirst.mockResolvedValue({ bundle: '__BUNDLE__', id: 'target', ...stored }); + virtualizationService.eval.mockResolvedValue({ + isErr: () => false, + value: { ...existingSeries, id: 'target' } + } as any); + instrumentModel.update.mockImplementation(({ data }: any) => Promise.resolve({ id: 'target', ...data })); + }; + + it('should look the series up within what the caller may update', async () => { + instrumentModel.findFirst.mockResolvedValue(null); + await expect(instrumentsService.updateSeriesArchive('target', { isArchived: true }, currentUser)).rejects.toThrow( + NotFoundException + ); + expect(instrumentModel.findFirst).toHaveBeenCalledWith({ + where: { AND: [accessibleQuery(ability, 'update', 'Instrument')], id: 'target' } + }); + }); + + it('should refuse a scalar instrument, since only series can be archived', 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.updateSeriesArchive('scalar', { isArchived: true }, currentUser)).rejects.toThrow( + ForbiddenException + ); + expect(instrumentModel.update).not.toHaveBeenCalled(); + }); + + it('should stamp when an active series was archived and audit it under its owning group', async () => { + storeSeries({ archivedAt: null, seriesGroupId: 'group-1' }); + + const result = await instrumentsService.updateSeriesArchive('target', { isArchived: true }, currentUser); + + expect(result.archivedAt).toBeInstanceOf(Date); + expect(auditLogger.log).toHaveBeenCalledWith('ARCHIVE', 'INSTRUMENT', { + groupId: 'group-1', + metadata: { instrumentId: 'target', title: 'Existing Series' }, + userId: 'admin-1' + }); + }); + + it('should clear the archive date when unarchiving, and audit a shared series under no group', async () => { + storeSeries({ archivedAt: new Date('2024-06-01T00:00:00.000Z'), seriesGroupId: null }); + + const result = await instrumentsService.updateSeriesArchive('target', { isArchived: false }, currentUser); + + expect(instrumentModel.update).toHaveBeenCalledWith({ data: { archivedAt: null }, where: { id: 'target' } }); + expect(result.archivedAt).toBeNull(); + expect(auditLogger.log).toHaveBeenCalledWith( + 'UNARCHIVE', + 'INSTRUMENT', + expect.objectContaining({ groupId: null }) + ); + }); + + it('should keep the original archive date when archiving again, so the date records when it was retired', async () => { + const archivedAt = new Date('2024-06-01T00:00:00.000Z'); + storeSeries({ archivedAt, seriesGroupId: 'group-1' }); + + const result = await instrumentsService.updateSeriesArchive('target', { isArchived: true }, currentUser); + + expect(result.archivedAt).toBe(archivedAt); + expect(instrumentModel.update).not.toHaveBeenCalled(); + expect(auditLogger.log).not.toHaveBeenCalled(); + }); + }); + describe('find', () => { beforeEach(() => { instrumentModel.findMany.mockResolvedValue([]); diff --git a/apps/api/src/instruments/instruments.controller.ts b/apps/api/src/instruments/instruments.controller.ts index 04b256972..01049561b 100644 --- a/apps/api/src/instruments/instruments.controller.ts +++ b/apps/api/src/instruments/instruments.controller.ts @@ -1,18 +1,24 @@ import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, Patch, Post, Query } from '@nestjs/common'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; // 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 { $CreateInstrumentData, $CreateSeriesInstrumentData } from '@opendatacapture/schemas/instrument'; +import { + $CreateInstrumentData, + $CreateSeriesInstrumentData, + $UpdateSeriesInstrumentData +} from '@opendatacapture/schemas/instrument'; import type { CreateSeriesInstrumentResult, InstrumentBundleContainer, - InstrumentInfo + InstrumentInfo, + SeriesInstrumentOverview } from '@opendatacapture/schemas/instrument'; +import type { AppAbility } from '@/auth/auth.types'; import { AcceptsInstrumentToken } from '@/core/decorators/accepts-instrument-token.decorator'; -import { RouteAccess } from '@/core/decorators/route-access.decorator'; +import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; import { InstrumentsService } from './instruments.service'; @@ -69,6 +75,13 @@ export class InstrumentsController { return this.instrumentsService.findInfo({ allEditions, kind, subjectId }, currentUser, groupId); } + @ApiOperation({ summary: 'List Every Series Instrument' }) + @Get('series') + @RouteAccess(ADMIN_ONLY) + findSeriesOverview(@CurrentUser('ability') ability: AppAbility): Promise { + return this.instrumentsService.findSeriesOverview({ ability }); + } + @ApiOperation({ summary: 'List Instruments' }) @Get('list') @RouteAccess({ action: 'read', subject: 'Instrument' }) @@ -79,4 +92,15 @@ export class InstrumentsController { ) { return this.instrumentsService.list({ kind }, currentUser, groupId); } + + @ApiOperation({ summary: 'Archive or Unarchive a Series Instrument' }) + @Patch('series/:id') + @RouteAccess(ADMIN_ONLY) + updateSeriesArchive( + @Param('id') id: string, + @Body() data: $UpdateSeriesInstrumentData, + @CurrentUser() currentUser: RequestUser + ): Promise<{ archivedAt: Date | null; id: string }> { + return this.instrumentsService.updateSeriesArchive(id, data, currentUser); + } } diff --git a/apps/api/src/instruments/instruments.service.ts b/apps/api/src/instruments/instruments.service.ts index 62ac829b1..d0e4c21ea 100644 --- a/apps/api/src/instruments/instruments.service.ts +++ b/apps/api/src/instruments/instruments.service.ts @@ -26,15 +26,18 @@ import type { WithID } from '@opendatacapture/schemas/core'; import { $AnyInstrument, $CreateInstrumentData } from '@opendatacapture/schemas/instrument'; import type { $CreateSeriesInstrumentData, + $UpdateSeriesInstrumentData, CreateSeriesInstrumentResult, InstrumentBundleContainer, InstrumentInfo, ScalarInstrumentBundleContainer, ScalarInstrumentInfo, - SeriesInstrumentInfo + SeriesInstrumentInfo, + SeriesInstrumentOverview } from '@opendatacapture/schemas/instrument'; import { pick } from 'lodash-es'; +import { AuditLogger } from '@/audit/audit.logger'; import { accessibleQuery } from '@/auth/ability.utils'; import type { AppAbility } from '@/auth/auth.types'; import type { EntityOperationOptions } from '@/core/types'; @@ -49,6 +52,7 @@ type InstrumentVirtualizationContext = { }; type InstrumentMetadata = { + archivedAt: Date | null; createdAt: Date; seriesGroupId: null | string; sourceRepoId: null | string; @@ -73,6 +77,7 @@ export class InstrumentsService { @InjectModel('Group') private readonly groupModel: Model<'Group'>, @InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'>, @InjectModel('InstrumentRecord') private readonly instrumentRecordModel: Model<'InstrumentRecord'>, + private readonly auditLogger: AuditLogger, private readonly cryptoService: CryptoService, private readonly loggingService: LoggingService, private readonly virtualizationService: VirtualizationService @@ -318,7 +323,12 @@ export class InstrumentsService { currentUser?: RequestUser, requestedGroupId?: string ): Promise { - const groupIds = currentUser ? this.resolveGroupIds(currentUser, requestedGroupId) : undefined; + // An administrator belongs to no group, yet previews every group's series from the admin pages, so + // their bundle lookups are not narrowed to their own groups; `accessibleQuery` still applies. + const groupIds = + currentUser && !currentUser.ability.can('manage', 'all') + ? this.resolveGroupIds(currentUser, requestedGroupId) + : undefined; const instance = await this.findById(id, { ability: currentUser?.ability }, groupIds); if (isScalarInstrument(instance)) { return { @@ -368,84 +378,32 @@ export class InstrumentsService { } async findInfo( - { allEditions = false, ...query }: InstrumentInfoQuery = {}, + query: InstrumentInfoQuery = {}, currentUser?: RequestUser, requestedGroupId?: string ): Promise { - const options = { ability: currentUser?.ability }; const groupIds = currentUser ? this.resolveGroupIds(currentUser, requestedGroupId) : undefined; - const instances = await this.find(query, options, groupIds); - - const metadataMap = await this.buildInstrumentMetadataMap(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, groupIds) : 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 metadata = metadataMap.get(instance.id); - const base = { - ...pick(instance, ['__runtimeVersion', 'clientDetails', 'details', 'id', 'language', 'tags']), - createdAt: metadata?.createdAt ?? null - }; - // 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 still treats those as repo-sourced via their id. - const sourceRepo = metadata?.sourceRepoId - ? { id: metadata.sourceRepoId, name: metadata.sourceRepoName ?? null } - : null; - - if (isSeriesInstrument(instance)) { - const seriesItems: { id: string }[] = []; - for (const { edition, name } of getSeriesInstrumentItems(instance.content)) { - const itemId = scalarInstrumentIds.get(`${name}:${edition}`); - if (!itemId) { - // Callers use `seriesItems` to grant a group access to a series' constituent instruments, - // so a silently dropped item becomes a failure part-way through administering the series. - this.loggingService.error({ - message: `Cannot resolve item '${name}' (edition ${edition}) of series instrument '${instance.id}'`, - seriesInstrumentId: instance.id - }); - continue; - } - seriesItems.push({ id: itemId }); - } - const info: SeriesInstrumentInfo = { - ...base, - kind: 'SERIES', - seriesGroupId: metadata?.seriesGroupId ?? null, - seriesItems, - sourceRepo - }; - results.set(info.id, info); - continue; - } + return this.findInfoWithinGroups(query, { ability: currentUser?.ability }, groupIds); + } - const info: ScalarInstrumentInfo = { - ...base, - internal: instance.internal, - kind: instance.kind, - sourceRepo - }; - if (allEditions) { - results.set(info.id, info); - } else { - const currentEntry = results.get(info.internal.name); - if (!currentEntry || !('internal' in currentEntry) || info.internal.edition > currentEntry.internal.edition) { - results.set(info.internal.name, info); - } - } - } - return Array.from(results.values()); + /** + * Every series on the instance, whichever group owns it, with that group's name. Unlike `findInfo`, + * this is not narrowed to the caller's groups: it backs the administrators' overview, and an + * administrator belongs to no group, so the narrowing would hide every owned series from them. + */ + async findSeriesOverview({ ability }: EntityOperationOptions = {}): Promise { + const infos = await this.findInfoWithinGroups({ kind: 'SERIES' }, { ability }); + const seriesInfos = infos.filter((info): info is SeriesInstrumentInfo => info.kind === 'SERIES'); + const ownerIds = [...new Set(seriesInfos.flatMap((info) => (info.seriesGroupId ? [info.seriesGroupId] : [])))]; + const groups = await this.groupModel.findMany({ + select: { id: true, name: true }, + where: { AND: [accessibleQuery(ability, 'read', 'Group')], id: { in: ownerIds } } + }); + const groupsById = new Map(groups.map((group) => [group.id, group])); + return seriesInfos.map((info) => ({ + ...info, + seriesGroup: info.seriesGroupId ? (groupsById.get(info.seriesGroupId) ?? null) : null + })); } generateInstrumentId(instrument: AnyInstrument, seriesGroupId?: string) { @@ -499,6 +457,41 @@ export class InstrumentsService { }); } + /** + * Retire a series from new sessions and assignments, or return it to service. Already-collected + * records and outstanding assignments are deliberately left alone, so archiving loses no data and + * is fully reversible. Setting the state it already has changes nothing and is not audited. + */ + async updateSeriesArchive( + id: string, + { isArchived }: $UpdateSeriesInstrumentData, + currentUser: RequestUser + ): Promise<{ archivedAt: Date | null; id: string }> { + const instrument = await this.instrumentModel.findFirst({ + where: { AND: [accessibleQuery(currentUser.ability, 'update', '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 archived'); + } + if (Boolean(instrument.archivedAt) === isArchived) { + return { archivedAt: instrument.archivedAt, id }; + } + const updated = await this.instrumentModel.update({ + data: { archivedAt: isArchived ? new Date() : null }, + where: { id } + }); + await this.auditLogger.log(isArchived ? 'ARCHIVE' : 'UNARCHIVE', 'INSTRUMENT', { + groupId: instrument.seriesGroupId, + metadata: { instrumentId: id, title: this.describeTitle(instance.details.title) }, + userId: currentUser.id + }); + return { archivedAt: updated.archivedAt, id }; + } + /** * Map of instrument id -> the stored columns `findInfo` reports but cannot read off an evaluated * instance: repository provenance, the owning group of a generated series, and when it was stored. @@ -511,11 +504,19 @@ export class InstrumentsService { return map; } const instruments = await this.instrumentModel.findMany({ - select: { createdAt: true, id: true, seriesGroupId: true, sourceRepoId: true, sourceRepoName: true }, + select: { + archivedAt: true, + createdAt: true, + id: true, + seriesGroupId: true, + sourceRepoId: true, + sourceRepoName: true + }, where: { id: { in: ids } } }); for (const inst of instruments) { map.set(inst.id, { + archivedAt: inst.archivedAt, createdAt: inst.createdAt, seriesGroupId: inst.seriesGroupId, sourceRepoId: inst.sourceRepoId, @@ -536,6 +537,99 @@ export class InstrumentsService { >; } + /** + * The single source of this message. `InstrumentReposService.importInstrumentFromDir` reads the id + * back out of it to associate an already-stored instrument with the repository that provides it, so + * the id must stay quoted and the two throw sites must stay identical. + */ + /** Audit metadata holds plain strings, so a multilingual title is recorded in English when it has one. */ + private describeTitle(title: SeriesInstrument['details']['title']): string { + if (typeof title === 'string') { + return title; + } + return title.en ?? Object.values(title).join(' / '); + } + + private async findInfoWithinGroups( + { allEditions = false, ...query }: InstrumentInfoQuery, + options: EntityOperationOptions, + groupIds?: string[] + ): Promise { + const instances = await this.find(query, options, groupIds); + + const metadataMap = await this.buildInstrumentMetadataMap(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, groupIds) : 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 metadata = metadataMap.get(instance.id); + const base = { + ...pick(instance, ['__runtimeVersion', 'clientDetails', 'details', 'id', 'language', 'tags']), + createdAt: metadata?.createdAt ?? null + }; + // 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 still treats those as repo-sourced via their id. + const sourceRepo = metadata?.sourceRepoId + ? { id: metadata.sourceRepoId, name: metadata.sourceRepoName ?? null } + : null; + + if (isSeriesInstrument(instance)) { + const seriesItems: { id: string }[] = []; + for (const { edition, name } of getSeriesInstrumentItems(instance.content)) { + const itemId = scalarInstrumentIds.get(`${name}:${edition}`); + if (!itemId) { + // Callers use `seriesItems` to grant a group access to a series' constituent instruments, + // so a silently dropped item becomes a failure part-way through administering the series. + this.loggingService.error({ + message: `Cannot resolve item '${name}' (edition ${edition}) of series instrument '${instance.id}'`, + seriesInstrumentId: instance.id + }); + continue; + } + seriesItems.push({ id: itemId }); + } + const info: SeriesInstrumentInfo = { + ...base, + archivedAt: metadata?.archivedAt ?? null, + kind: 'SERIES', + seriesGroupId: metadata?.seriesGroupId ?? null, + seriesItems, + sourceRepo + }; + results.set(info.id, info); + continue; + } + + const info: ScalarInstrumentInfo = { + ...base, + internal: instance.internal, + kind: instance.kind, + sourceRepo + }; + if (allEditions) { + results.set(info.id, info); + } else { + const currentEntry = results.get(info.internal.name); + if (!currentEntry || !('internal' in currentEntry) || info.internal.edition > currentEntry.internal.edition) { + results.set(info.internal.name, info); + } + } + } + return Array.from(results.values()); + } + /** * The ids of the instruments the subject has at least one record for. * @@ -629,11 +723,6 @@ export class InstrumentsService { ); } - /** - * The single source of this message. `InstrumentReposService.importInstrumentFromDir` reads the id - * back out of it to associate an already-stored instrument with the repository that provides it, so - * the id must stay quoted and the two throw sites must stay identical. - */ private instrumentExistsConflict(id: string): ConflictException { return new ConflictException(`Instrument with ID '${id}' already exists!`); } diff --git a/apps/web/src/components/InstrumentPreviewDialog/InstrumentPreviewDialog.tsx b/apps/web/src/components/InstrumentPreviewDialog/InstrumentPreviewDialog.tsx new file mode 100644 index 000000000..225525f95 --- /dev/null +++ b/apps/web/src/components/InstrumentPreviewDialog/InstrumentPreviewDialog.tsx @@ -0,0 +1,202 @@ +import { useMemo, useState } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { Button, Dialog, Spinner } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { InstrumentRenderer } from '@opendatacapture/react-core'; +import type { ScalarInstrumentInternal } from '@opendatacapture/runtime-core'; + +import { useInstrumentBundle } from '@/hooks/useInstrumentBundle'; +import type { SeriesAvailability } from '@/utils/series-availability'; + +type InstrumentSource = { kind: 'manual' } | { kind: 'repo'; name: string }; + +type InstrumentPreviewItem = { + authors?: null | string[]; + // Null for a scalar instrument: only a series is ever owned by a single group. + availability: null | SeriesAvailability; + // When the instrument was stored: uploaded, imported from a repository, or built as a series. + createdAt: Date | null; + 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; +}; + +/** Passed to the renderer as a localizable value; the shared component resolves it to the active language. */ +const PREVIEW_SUBMIT_LABEL = { en: 'Preview Submit', es: 'Vista previa del envío', fr: 'Soumettre l’aperçu' }; + +const getSeriesPreviewItemTitles = ({ + fallbackTitle, + items, + seriesItems +}: { + fallbackTitle: (index: number) => string; + items: { id: string; title: string }[]; + seriesItems: { id: string }[]; +}) => { + return seriesItems.map((seriesItem, index) => { + return items.find((item) => item.id === seriesItem.id)?.title ?? fallbackTitle(index); + }); +}; + +type InstrumentPreviewDialogProps = { + item: InstrumentPreviewItem; + // Every instrument the series' items may refer to, so they can be listed by title. + items: { id: string; title: string }[]; + onClose: () => void; +}; + +/** An instrument's details, and a rendering of its form that submits nothing. */ +export const InstrumentPreviewDialog = ({ item, items, onClose }: InstrumentPreviewDialogProps) => { + const { t } = useTranslation(); + const [showForm, setShowForm] = useState(false); + // Only the rendered preview needs the bundle. Series composition comes from `item.seriesItems`, which + // the info query already provides — fetching the bundle for it would pull down the compiled source of + // every constituent instrument just to list their names. + const bundleQuery = useInstrumentBundle(showForm ? item.id : null); + const seriesItemTitles = useMemo(() => { + return getSeriesPreviewItemTitles({ + fallbackTitle: (index) => t({ en: `Item ${index + 1}`, es: `Elemento ${index + 1}`, fr: `Élément ${index + 1}` }), + items, + seriesItems: item.seriesItems ?? [] + }); + }, [item.seriesItems, items, t]); + + return ( + { + if (!open) onClose(); + }} + > + + + {item.title} + + {showForm ? ( +
+ {bundleQuery.isLoading && ( +
+ +
+ )} + {bundleQuery.isError && ( +

+ {t({ + en: 'Failed to load instrument preview.', + es: 'Error al cargar la vista previa del instrumento.', + fr: "Échec du chargement de l'aperçu de l'instrument." + })} +

+ )} + {bundleQuery.data && ( + { + // Intentionally does nothing: previews can advance without creating records. + }} + /> + )} +
+ ) : ( +
+
+
+ {t({ en: 'Kind', es: 'Tipo', fr: 'Type' })}: + {item.kind} +
+ {item.description && ( +
+ + {t({ en: 'Description', es: 'Descripción', fr: 'Description' })}:{' '} + + {item.description} +
+ )} + {item.kind === 'SERIES' && ( +
+ + {t({ en: 'Series order', es: 'Orden de la serie', fr: 'Ordre de la série' })} + {seriesItemTitles.length > 0 && ` (${seriesItemTitles.length})`}:{' '} + + {seriesItemTitles.length === 0 ? ( + + {t({ + en: 'No items in this series.', + es: 'No hay elementos en esta serie.', + fr: 'Aucun élément dans cette série.' + })} + + ) : ( +
    + {seriesItemTitles.map((title, index) => ( +
  1. + {title} +
  2. + ))} +
+ )} +
+ )} + {item.authors && item.authors.length > 0 && ( +
+ {t({ en: 'Authors', es: 'Autores', fr: 'Auteurs' })}: + {item.authors.join(', ')} +
+ )} + {item.internal && ( +
+ {t({ en: 'Edition', es: 'Edición', fr: 'Édition' })}: + {item.internal.edition} +
+ )} +
+ {t({ en: 'Source', es: 'Origen', fr: 'Source' })}: + + {item.source.kind === 'repo' + ? item.source.name + : t({ + en: 'No repo; it was manually added to the platform', + es: 'Sin repositorio; se agregó manualmente a la plataforma', + fr: 'Aucun dépôt ; ajouté manuellement à la plateforme' + })} + +
+ {item.createdAt && ( +
+ {t({ en: 'Added', es: 'Agregado el', fr: 'Ajouté le' })}: + {toBasicISOString(item.createdAt)} +
+ )} + {item.availability && ( +
+ + {t({ en: 'Available to', es: 'Disponible para', fr: 'Disponible pour' })}:{' '} + + + {item.availability.kind === 'all' + ? t({ en: 'All groups', es: 'Todos los grupos', fr: 'Tous les groupes' }) + : (item.availability.name ?? t({ en: 'Another group', es: 'Otro grupo', fr: 'Un autre groupe' }))} + +
+ )} +
+
+ +
+
+ )} +
+
+ ); +}; + +export type { InstrumentPreviewItem, InstrumentSource }; diff --git a/apps/web/src/components/InstrumentPreviewDialog/index.ts b/apps/web/src/components/InstrumentPreviewDialog/index.ts new file mode 100644 index 000000000..cd2f180d8 --- /dev/null +++ b/apps/web/src/components/InstrumentPreviewDialog/index.ts @@ -0,0 +1 @@ +export * from './InstrumentPreviewDialog'; diff --git a/apps/web/src/hooks/__tests__/useSeriesInstrumentsOverviewQuery.test.ts b/apps/web/src/hooks/__tests__/useSeriesInstrumentsOverviewQuery.test.ts new file mode 100644 index 000000000..4edcf5ea1 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useSeriesInstrumentsOverviewQuery.test.ts @@ -0,0 +1,45 @@ +import { QueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { seriesInstrumentsOverviewQueryOptions } from '../useSeriesInstrumentsOverviewQuery'; + +vi.mock('axios'); + +// eslint-disable-next-line @typescript-eslint/unbound-method -- a vitest mock, never invoked as a method +const get = vi.mocked(axios).get; + +const series = { + __runtimeVersion: 1, + archivedAt: '2024-06-01T00:00:00.000Z', + createdAt: '2024-03-01T00:00:00.000Z', + details: { description: 'A series', license: 'UNLICENSED', title: 'Intake' }, + id: 'series-1', + kind: 'SERIES', + language: 'en', + seriesGroup: { id: 'group-1', name: 'Depression Clinic' }, + seriesGroupId: 'group-1', + seriesItems: [], + tags: ['Series'] +}; + +describe('seriesInstrumentsOverviewQueryOptions', () => { + beforeEach(() => { + get.mockReset(); + }); + + it('should read the overview of every series from the administrators endpoint', async () => { + get.mockResolvedValueOnce({ data: [] }); + await new QueryClient().fetchQuery(seriesInstrumentsOverviewQueryOptions()); + expect(get).toHaveBeenCalledWith('/v1/instruments/series'); + }); + + it('should parse the archive date into a date, so the page can tell archived series from active ones', async () => { + get.mockResolvedValueOnce({ data: [series] }); + const [result] = await new QueryClient().fetchQuery(seriesInstrumentsOverviewQueryOptions()); + expect(result).toMatchObject({ + archivedAt: new Date('2024-06-01T00:00:00.000Z'), + seriesGroup: { name: 'Depression Clinic' } + }); + }); +}); diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index 4a068e9b8..b80094936 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -4,10 +4,13 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { BarChartBigIcon, CirclePlayIcon, + ClipboardListIcon, CogIcon, ComputerIcon, DatabaseIcon, EyeIcon, + FileTextIcon, + LayersIcon, LogsIcon, MailIcon, PackageIcon, @@ -202,6 +205,28 @@ export function useNavItems() { }), url: '/admin/instrument-repos' }, + { + children: [ + { + icon: FileTextIcon, + label: t({ + en: 'Form & Interactive', + es: 'Formularios e interactivos', + fr: 'Formulaires et interactifs' + }), + search: { view: 'forms' }, + url: '/admin/instruments' + }, + { + icon: LayersIcon, + label: t({ en: 'Series', es: 'Series', fr: 'Séries' }), + search: { view: 'series' }, + url: '/admin/instruments' + } + ], + icon: ClipboardListIcon, + label: t({ en: 'Instruments', es: 'Instrumentos', fr: 'Instruments' }) + }, { icon: MailIcon, label: t({ en: 'Mail', es: 'Correo', fr: 'Courriel' }), diff --git a/apps/web/src/hooks/useSeriesInstrumentsOverviewQuery.ts b/apps/web/src/hooks/useSeriesInstrumentsOverviewQuery.ts new file mode 100644 index 000000000..6753c3d4a --- /dev/null +++ b/apps/web/src/hooks/useSeriesInstrumentsOverviewQuery.ts @@ -0,0 +1,19 @@ +import { $SeriesInstrumentOverview } from '@opendatacapture/schemas/instrument'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export const SERIES_INSTRUMENTS_OVERVIEW_QUERY_KEY = 'series-instruments-overview'; + +export const seriesInstrumentsOverviewQueryOptions = () => { + return queryOptions({ + queryFn: async () => { + const response = await axios.get('/v1/instruments/series'); + return $SeriesInstrumentOverview.array().parse(response.data); + }, + queryKey: [SERIES_INSTRUMENTS_OVERVIEW_QUERY_KEY] + }); +}; + +export function useSeriesInstrumentsOverviewQuery() { + return useSuspenseQuery(seriesInstrumentsOverviewQueryOptions()); +} diff --git a/apps/web/src/hooks/useUpdateSeriesInstrumentArchiveMutation.ts b/apps/web/src/hooks/useUpdateSeriesInstrumentArchiveMutation.ts new file mode 100644 index 000000000..7aa4c43cc --- /dev/null +++ b/apps/web/src/hooks/useUpdateSeriesInstrumentArchiveMutation.ts @@ -0,0 +1,40 @@ +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { $UpdateSeriesInstrumentData } from '@opendatacapture/schemas/instrument'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +import { getApiErrorMessage } from '@/utils/error'; + +import { SERIES_INSTRUMENTS_OVERVIEW_QUERY_KEY } from './useSeriesInstrumentsOverviewQuery'; + +/** Archive a series, retiring it from new sessions and assignments, or return it to service. */ +export function useUpdateSeriesInstrumentArchiveMutation() { + const queryClient = useQueryClient(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const { t } = useTranslation(); + return useMutation({ + mutationFn: ({ id, ...data }: $UpdateSeriesInstrumentData & { id: string }) => + axios.patch(`/v1/instruments/series/${id}`, data, { meta: { disableDefaultErrorNotification: true } }), + onError(err) { + addNotification({ + message: getApiErrorMessage( + err, + t({ + en: 'Failed to update the series instrument', + es: 'Error al actualizar el instrumento en serie', + fr: "Échec de la mise à jour de l'instrument en série" + }) + ), + type: 'error' + }); + }, + onSuccess() { + addNotification({ type: 'success' }); + void queryClient.invalidateQueries({ queryKey: [SERIES_INSTRUMENTS_OVERVIEW_QUERY_KEY] }); + // Every picker filters archived series out of the instrument info, so it must refetch too. + void queryClient.invalidateQueries({ queryKey: ['instrument-info'] }); + }, + // A refusal is reported by the notification above rather than handed to the route error boundary. + throwOnError: false + }); +} diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index 5485ea27e..bc5f4dd63 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -29,6 +29,7 @@ import { Route as AppGroupManageRouteImport } from './routes/_app/group/manage' import { Route as AppGroupEmailTemplatesRouteImport } from './routes/_app/group/email-templates' import { Route as AppAdminSettingsRouteImport } from './routes/_app/admin/settings' import { Route as AppAdminMailRouteImport } from './routes/_app/admin/mail' +import { Route as AppAdminInstrumentsRouteImport } from './routes/_app/admin/instruments' import { Route as AppDatahubSubjectIdRouteRouteImport } from './routes/_app/datahub/$subjectId/route' import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index' import { Route as AppAdminInstrumentReposIndexRouteImport } from './routes/_app/admin/instrument-repos/index' @@ -147,6 +148,11 @@ const AppAdminMailRoute = AppAdminMailRouteImport.update({ path: '/admin/mail', getParentRoute: () => AppRouteRoute, } as any) +const AppAdminInstrumentsRoute = AppAdminInstrumentsRouteImport.update({ + id: '/admin/instruments', + path: '/admin/instruments', + getParentRoute: () => AppRouteRoute, +} as any) const AppDatahubSubjectIdRouteRoute = AppDatahubSubjectIdRouteRouteImport.update({ id: '/datahub/$subjectId', @@ -240,6 +246,7 @@ export interface FileRoutesByFullPath { '/auth/login': typeof AuthLoginRoute '/auth/reset-password': typeof AuthResetPasswordRoute '/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren + '/admin/instruments': typeof AppAdminInstrumentsRoute '/admin/mail': typeof AppAdminMailRoute '/admin/settings': typeof AppAdminSettingsRoute '/group/email-templates': typeof AppGroupEmailTemplatesRoute @@ -276,6 +283,7 @@ export interface FileRoutesByTo { '/auth/reset-password': typeof AuthResetPasswordRoute '/': typeof AppIndexRoute '/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren + '/admin/instruments': typeof AppAdminInstrumentsRoute '/admin/mail': typeof AppAdminMailRoute '/admin/settings': typeof AppAdminSettingsRoute '/group/email-templates': typeof AppGroupEmailTemplatesRoute @@ -314,6 +322,7 @@ export interface FileRoutesById { '/auth/reset-password': typeof AuthResetPasswordRoute '/_app/': typeof AppIndexRoute '/_app/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren + '/_app/admin/instruments': typeof AppAdminInstrumentsRoute '/_app/admin/mail': typeof AppAdminMailRoute '/_app/admin/settings': typeof AppAdminSettingsRoute '/_app/group/email-templates': typeof AppGroupEmailTemplatesRoute @@ -352,6 +361,7 @@ export interface FileRouteTypes { | '/auth/login' | '/auth/reset-password' | '/datahub/$subjectId' + | '/admin/instruments' | '/admin/mail' | '/admin/settings' | '/group/email-templates' @@ -388,6 +398,7 @@ export interface FileRouteTypes { | '/auth/reset-password' | '/' | '/datahub/$subjectId' + | '/admin/instruments' | '/admin/mail' | '/admin/settings' | '/group/email-templates' @@ -425,6 +436,7 @@ export interface FileRouteTypes { | '/auth/reset-password' | '/_app/' | '/_app/datahub/$subjectId' + | '/_app/admin/instruments' | '/_app/admin/mail' | '/_app/admin/settings' | '/_app/group/email-templates' @@ -601,6 +613,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminMailRouteImport parentRoute: typeof AppRouteRoute } + '/_app/admin/instruments': { + id: '/_app/admin/instruments' + path: '/admin/instruments' + fullPath: '/admin/instruments' + preLoaderRoute: typeof AppAdminInstrumentsRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/datahub/$subjectId': { id: '/_app/datahub/$subjectId' path: '/datahub/$subjectId' @@ -737,6 +756,7 @@ interface AppRouteRouteChildren { AppUserRoute: typeof AppUserRoute AppIndexRoute: typeof AppIndexRoute AppDatahubSubjectIdRouteRoute: typeof AppDatahubSubjectIdRouteRouteWithChildren + AppAdminInstrumentsRoute: typeof AppAdminInstrumentsRoute AppAdminMailRoute: typeof AppAdminMailRoute AppAdminSettingsRoute: typeof AppAdminSettingsRoute AppGroupEmailTemplatesRoute: typeof AppGroupEmailTemplatesRoute @@ -767,6 +787,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppUserRoute: AppUserRoute, AppIndexRoute: AppIndexRoute, AppDatahubSubjectIdRouteRoute: AppDatahubSubjectIdRouteRouteWithChildren, + AppAdminInstrumentsRoute: AppAdminInstrumentsRoute, AppAdminMailRoute: AppAdminMailRoute, AppAdminSettingsRoute: AppAdminSettingsRoute, AppGroupEmailTemplatesRoute: AppGroupEmailTemplatesRoute, diff --git a/apps/web/src/routes/_app/admin/audit/logs.tsx b/apps/web/src/routes/_app/admin/audit/logs.tsx index c2176e503..372dd86e1 100644 --- a/apps/web/src/routes/_app/admin/audit/logs.tsx +++ b/apps/web/src/routes/_app/admin/audit/logs.tsx @@ -23,7 +23,7 @@ type DateFormat = 'iso' | 'local'; type SortOrder = 'asc' | 'desc'; -const ACTIONS: $AuditLogAction[] = ['CREATE', 'DELETE', 'UPDATE', 'LOGIN', 'SEND_EMAIL']; +const ACTIONS: $AuditLogAction[] = ['CREATE', 'DELETE', 'UPDATE', 'ARCHIVE', 'UNARCHIVE', 'LOGIN', 'SEND_EMAIL']; const ENTITIES: $AuditLogEntity[] = [ 'ASSIGNMENT', diff --git a/apps/web/src/routes/_app/admin/instruments.tsx b/apps/web/src/routes/_app/admin/instruments.tsx new file mode 100644 index 000000000..6bf3f6c2d --- /dev/null +++ b/apps/web/src/routes/_app/admin/instruments.tsx @@ -0,0 +1,404 @@ +import { useState } from 'react'; +import type { ComponentProps } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { translateInstrumentInfo } from '@opendatacapture/instrument-utils'; +import type { InstrumentInfo, TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; +import { createFileRoute } from '@tanstack/react-router'; +import { z } from 'zod/v4'; + +import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; +import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog'; +import { PageHeader } from '@/components/PageHeader'; +import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery'; +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { + seriesInstrumentsOverviewQueryOptions, + useSeriesInstrumentsOverviewQuery +} from '@/hooks/useSeriesInstrumentsOverviewQuery'; +import { useUpdateSeriesInstrumentArchiveMutation } from '@/hooks/useUpdateSeriesInstrumentArchiveMutation'; +import { selectLatestEditions } from '@/utils/instrument-editions'; +import { sortSeriesOverviewRows } from '@/utils/series-overview'; +import type { SeriesOverviewRow } from '@/utils/series-overview'; + +type SeriesRow = SeriesOverviewRow & { + createdAt: Date | null; + id: string; + preview: InstrumentPreviewItem; +}; + +/** One line cut off with an ellipsis, with the whole text shown on hover. */ +const CellText = ({ className, text, ...props }: ComponentProps<'span'> & { text: string }) => ( + + {text} + +); + +const ArchiveDialog = ({ onClose, row }: { onClose: () => void; row: SeriesRow }) => { + const { t } = useTranslation(); + const archiveMutation = useUpdateSeriesInstrumentArchiveMutation(); + const groupLabel = row.groupName ?? t({ en: 'All groups', es: 'Todos los grupos', fr: 'Tous les groupes' }); + + return ( + { + if (!open) onClose(); + }} + > + + + + {t({ + en: 'Archive Series Instrument', + es: 'Archivar serie de instrumentos', + fr: 'Archiver l’instrument en série' + })} + + + {t({ + en: `"${row.title}" (${groupLabel}) will no longer be offered for new sessions or remote assignments. Data already collected with it, and remote assignments still outstanding, are not affected. You can unarchive it at any time.`, + es: `"${row.title}" (${groupLabel}) dejará de ofrecerse para nuevas sesiones o tareas remotas. Los datos ya recopilados y las tareas remotas pendientes no se verán afectados. Puede desarchivarlo en cualquier momento.`, + fr: `« ${row.title} » (${groupLabel}) ne sera plus proposé pour de nouvelles sessions ou tâches à distance. Les données déjà recueillies et les tâches à distance en cours ne sont pas affectées. Vous pouvez le désarchiver à tout moment.` + })} + + + + + + + + + ); +}; + +type InstrumentRow = { + createdAt: Date | null; + edition: number; + groupNames: string[]; + id: string; + kind: string; + preview: InstrumentPreviewItem; + sourceLabel: string; + title: string; +}; + +type InstrumentViewProps = { + // Every scalar instrument, of every edition, so a series' items can be named in its preview. + scalarInstruments: TranslatedInstrumentInfo[]; +}; + +const useInstrumentSource = () => { + const { t } = useTranslation(); + return (sourceRepo: InstrumentInfo['sourceRepo']): InstrumentSource => + sourceRepo + ? { + kind: 'repo', + name: sourceRepo.name ?? t({ en: 'Unknown repository', es: 'Repositorio desconocido', fr: 'Dépôt inconnu' }) + } + : { kind: 'manual' }; +}; + +const FormInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { + const { t } = useTranslation(); + const groupsQuery = useGroupsQuery(); + const toSource = useInstrumentSource(); + const [previewItem, setPreviewItem] = useState(null); + + const rows: InstrumentRow[] = selectLatestEditions( + scalarInstruments.flatMap((info) => (info.kind === 'SERIES' ? [] : [info])) + ) + .map((info) => { + const source = toSource(info.sourceRepo); + return { + createdAt: info.createdAt ?? null, + edition: info.internal.edition, + groupNames: groupsQuery.data + .filter((group) => group.accessibleInstrumentIds.includes(info.id)) + .map((group) => group.name), + id: info.id, + kind: info.kind, + preview: { + authors: info.details.authors, + availability: null, + createdAt: info.createdAt ?? null, + description: info.details.description, + id: info.id, + internal: info.internal, + kind: info.kind, + source, + title: info.details.title + }, + sourceLabel: source.kind === 'repo' ? source.name : t({ en: 'Manual', es: 'Manual', fr: 'Manuel' }), + title: info.details.title + }; + }) + .toSorted((a, b) => a.title.localeCompare(b.title)); + + return ( + <> + ()} />, + header: t({ en: 'Name', es: 'Nombre', fr: 'Nom' }) + }, + { + accessorKey: 'sourceLabel', + cell: (ctx) => ()} />, + header: t({ en: 'Source', es: 'Origen', fr: 'Source' }) + }, + { + accessorFn: (row: InstrumentRow) => + row.groupNames.length > 0 ? row.groupNames.join(', ') : t({ en: 'None', es: 'Ninguno', fr: 'Aucun' }), + cell: (ctx) => ()} />, + header: t({ en: 'Groups Using It', es: 'Grupos que lo usan', fr: 'Groupes qui l’utilisent' }), + id: 'groups' + }, + { + accessorFn: (row: InstrumentRow) => + row.kind === 'INTERACTIVE' + ? t({ en: 'Interactive', es: 'Interactivo', fr: 'Interactif' }) + : row.kind === 'FILE' + ? t({ en: 'File', es: 'Archivo', fr: 'Fichier' }) + : t({ en: 'Form', es: 'Formulario', fr: 'Formulaire' }), + cell: (ctx) => ()} />, + header: t({ en: 'Kind', es: 'Tipo', fr: 'Type' }), + id: 'kind', + size: 130 + }, + { + accessorKey: 'edition', + cell: (ctx) => ())} />, + header: t({ en: 'Edition', es: 'Edición', fr: 'Édition' }), + size: 100 + }, + { + accessorFn: (row: InstrumentRow) => (row.createdAt ? toBasicISOString(row.createdAt) : '-'), + cell: (ctx) => ()} />, + header: t({ en: 'Added', es: 'Agregado', fr: 'Ajouté' }), + id: 'createdAt', + size: 130 + } + ]} + data={rows} + emptyStateProps={{ + title: t({ + en: 'No instruments have been added yet.', + es: 'Aún no se ha agregado ningún instrumento.', + fr: 'Aucun instrument n’a encore été ajouté.' + }) + }} + // libui shares the width evenly between unpinned columns and honours `size` only on pinned ones, + // so the short columns are pinned to leave the name the most room. + initialState={{ columnPinning: { right: ['kind', 'edition', 'createdAt'] } }} + rowActions={[ + { + label: t({ en: 'Preview', es: 'Vista previa', fr: 'Aperçu' }), + onSelect: (row: InstrumentRow) => setPreviewItem(row.preview) + } + ]} + // libui applies its row hover highlight only when a click handler is set; a single click does nothing here. + onRowClick={() => undefined} + onRowDoubleClick={(row) => setPreviewItem(row.preview)} + /> + {previewItem && ( + ({ id: info.id, title: info.details.title }))} + onClose={() => setPreviewItem(null)} + /> + )} + + ); +}; + +const SeriesInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { + const { resolvedLanguage, t } = useTranslation(); + const overviewQuery = useSeriesInstrumentsOverviewQuery(); + const unarchiveMutation = useUpdateSeriesInstrumentArchiveMutation(); + const toSource = useInstrumentSource(); + const [rowToArchive, setRowToArchive] = useState(null); + const [previewItem, setPreviewItem] = useState(null); + + const allGroupsLabel = t({ en: 'All groups', es: 'Todos los grupos', fr: 'Tous les groupes' }); + const formatStatus = ({ archivedAt }: SeriesRow) => { + if (!archivedAt) { + return t({ en: 'Active', es: 'Activo', fr: 'Actif' }); + } + const date = toBasicISOString(archivedAt); + return t({ en: `Archived on ${date}`, es: `Archivado el ${date}`, fr: `Archivé le ${date}` }); + }; + const toggleArchive = (row: SeriesRow) => { + if (row.archivedAt) { + unarchiveMutation.mutate({ id: row.id, isArchived: false }); + } else { + setRowToArchive(row); + } + }; + + const rows = sortSeriesOverviewRows( + overviewQuery.data.map((series) => { + const { details } = translateInstrumentInfo(series, resolvedLanguage); + return { + archivedAt: series.archivedAt, + createdAt: series.createdAt ?? null, + groupName: series.seriesGroup?.name ?? null, + id: series.id, + preview: { + authors: details.authors, + availability: series.seriesGroup ? { kind: 'group', name: series.seriesGroup.name } : { kind: 'all' }, + createdAt: series.createdAt ?? null, + description: details.description, + id: series.id, + internal: null, + kind: series.kind, + seriesItems: series.seriesItems, + source: toSource(series.sourceRepo), + title: details.title + }, + title: details.title + }; + }) + ); + + return ( + <> + ()} />, + header: t({ en: 'Name', es: 'Nombre', fr: 'Nom' }) + }, + { + accessorFn: (row: SeriesRow) => row.groupName ?? allGroupsLabel, + cell: (ctx) => ()} />, + header: t({ en: 'Group', es: 'Grupo', fr: 'Groupe' }), + id: 'group' + }, + { + accessorFn: (row: SeriesRow) => (row.createdAt ? toBasicISOString(row.createdAt) : '-'), + cell: (ctx) => ()} />, + header: t({ en: 'Created', es: 'Creado', fr: 'Créé' }), + id: 'createdAt', + size: 130 + }, + { + accessorFn: formatStatus, + cell: (ctx) => { + const row = ctx.row.original; + return ( + + ); + }, + header: t({ en: 'Status', es: 'Estado', fr: 'Statut' }), + id: 'status', + size: 200 + } + ]} + data={rows} + emptyStateProps={{ + title: t({ + en: 'No series instruments have been created yet.', + es: 'Aún no se ha creado ninguna serie de instrumentos.', + fr: 'Aucun instrument en série n’a encore été créé.' + }) + }} + // libui shares the width evenly between unpinned columns and honours `size` only on pinned ones, + // so the two short columns are pinned to leave the name the most room. + initialState={{ columnPinning: { right: ['createdAt', 'status'] } }} + rowActions={[ + { + label: t({ en: 'Preview', es: 'Vista previa', fr: 'Aperçu' }), + onSelect: (row: SeriesRow) => setPreviewItem(row.preview) + }, + { + disabled: (row: SeriesRow) => Boolean(row.archivedAt), + label: t('common.archive'), + onSelect: toggleArchive + }, + { + disabled: (row: SeriesRow) => !row.archivedAt || unarchiveMutation.isPending, + label: t('common.unarchive'), + onSelect: toggleArchive + } + ]} + // libui applies its row hover highlight only when a click handler is set; a single click does nothing here. + onRowClick={() => undefined} + onRowDoubleClick={(row) => { + if (!unarchiveMutation.isPending) { + toggleArchive(row); + } + }} + /> + {rowToArchive && setRowToArchive(null)} />} + {previewItem && ( + ({ id: info.id, title: info.details.title }))} + onClose={() => setPreviewItem(null)} + /> + )} + + ); +}; + +const RouteComponent = () => { + const { t } = useTranslation(); + const { view } = Route.useSearch(); + // Every edition, not only the latest: a series may name an older one, and its preview lists it. + const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); + const scalarInstruments = (instrumentInfoQuery.data ?? []).filter((info) => info.kind !== 'SERIES'); + + return ( +
+ + + {view === 'series' + ? t({ en: 'Series Instruments', es: 'Series de instrumentos', fr: 'Instruments en série' }) + : t({ + en: 'Form & Interactive Instruments', + es: 'Instrumentos de formulario e interactivos', + fr: 'Instruments de formulaire et interactifs' + })} + + + {view === 'series' ? ( + + ) : ( + + )} +
+ ); +}; + +export const Route = createFileRoute('/_app/admin/instruments')({ + component: RouteComponent, + loader: async ({ context }) => { + await Promise.all([ + context.queryClient.ensureQueryData(seriesInstrumentsOverviewQueryOptions()), + context.queryClient.ensureQueryData(groupsQueryOptions()) + ]); + }, + validateSearch: z.object({ view: z.enum(['forms', 'series']).catch('forms') }) +}); diff --git a/apps/web/src/routes/_app/group/manage.tsx b/apps/web/src/routes/_app/group/manage.tsx index fd6145de8..4a4ac9e0b 100644 --- a/apps/web/src/routes/_app/group/manage.tsx +++ b/apps/web/src/routes/_app/group/manage.tsx @@ -10,11 +10,9 @@ import { Heading, Input, SearchBar, - 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, toInstrumentAuthoringLanguage } from '@opendatacapture/schemas/core'; import type { $UpdateGroupData } from '@opendatacapture/schemas/group'; @@ -26,19 +24,17 @@ import { EyeIcon, TrashIcon } from 'lucide-react'; import type { Promisable } from 'type-fest'; import { z } from 'zod/v4'; +import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; +import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog'; import { PageHeader } from '@/components/PageHeader'; import { WithFallback } from '@/components/WithFallback'; import { useCreateSeriesInstrumentMutation } from '@/hooks/useCreateSeriesInstrumentMutation'; import { useDeleteSeriesInstrumentMutation } from '@/hooks/useDeleteSeriesInstrumentMutation'; -import { useInstrumentBundle } from '@/hooks/useInstrumentBundle'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useUpdateGroupMutation } from '@/hooks/useUpdateGroupMutation'; import { useAppStore } from '@/store'; import { buildSeriesAvailability } from '@/utils/series-availability'; -import type { SeriesAvailability } from '@/utils/series-availability'; - -type InstrumentSource = { kind: 'manual' } | { kind: 'repo'; name: string }; /** * The row's trailing columns, as one set of measurements: an ISO date is a fixed width, the trash is @@ -50,27 +46,11 @@ const COLUMN_GAP = '0.75rem'; const ACTION_WIDTH = '1.5rem'; const ACTION_GUTTER = `calc(${ACTION_WIDTH} + ${COLUMN_GAP})`; -/** Passed to the renderer as a localizable value; the shared component resolves it to the active language. */ -const PREVIEW_SUBMIT_LABEL = { en: 'Preview Submit', es: 'Vista previa del envío', fr: 'Soumettre l’aperçu' }; - -type InstrumentItem = { - authors?: null | string[]; - // Null for a scalar instrument: only a series is ever owned by a single group. - availability: null | SeriesAvailability; - // When the instrument was stored: uploaded, imported from a repository, or built here as a series. - createdAt: Date | null; - description?: string; - id: string; - // The scalar instrument identity (name + edition); null for series instruments, which have no edition. - internal: null | ScalarInstrumentInternal; +type InstrumentItem = InstrumentPreviewItem & { // Whether this group owns the instrument and may therefore delete it. Only a series created by this // group qualifies: scalar instruments are never deletable, and a series with no owning group is // shared across the whole instance. isDeletable: boolean; - kind: string; - seriesItems?: { id: string }[]; - source: InstrumentSource; - title: string; }; type CategorizedInstruments = { @@ -79,20 +59,6 @@ type CategorizedInstruments = { series: InstrumentItem[]; }; -const getSeriesPreviewItemTitles = ({ - fallbackTitle, - items, - seriesItems -}: { - fallbackTitle: (index: number) => string; - items: InstrumentItem[]; - seriesItems: { id: string }[]; -}) => { - return seriesItems.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) { @@ -260,161 +226,6 @@ const InstrumentSection = ({ ); }; -const InstrumentPreviewDialog = ({ - item, - items, - onClose -}: { - item: InstrumentItem; - items: InstrumentItem[]; - onClose: () => void; -}) => { - const { t } = useTranslation(); - const [showForm, setShowForm] = useState(false); - // Only the rendered preview needs the bundle. Series composition comes from `item.seriesItems`, which - // the info query already provides — fetching the bundle for it would pull down the compiled source of - // every constituent instrument just to list their names. - const bundleQuery = useInstrumentBundle(showForm ? item.id : null); - const seriesItemTitles = useMemo(() => { - return getSeriesPreviewItemTitles({ - fallbackTitle: (index) => t({ en: `Item ${index + 1}`, es: `Elemento ${index + 1}`, fr: `Élément ${index + 1}` }), - items, - seriesItems: item.seriesItems ?? [] - }); - }, [item.seriesItems, items, t]); - - return ( - { - if (!open) onClose(); - }} - > - - - {item.title} - - {showForm ? ( -
- {bundleQuery.isLoading && ( -
- -
- )} - {bundleQuery.isError && ( -

- {t({ - en: 'Failed to load instrument preview.', - es: 'Error al cargar la vista previa del instrumento.', - fr: "Échec du chargement de l'aperçu de l'instrument." - })} -

- )} - {bundleQuery.data && ( - { - // Intentionally does nothing: previews can advance without creating records. - }} - /> - )} -
- ) : ( -
-
-
- {t({ en: 'Kind', es: 'Tipo', fr: 'Type' })}: - {item.kind} -
- {item.description && ( -
- - {t({ en: 'Description', es: 'Descripción', fr: 'Description' })}:{' '} - - {item.description} -
- )} - {item.kind === 'SERIES' && ( -
- - {t({ en: 'Series order', es: 'Orden de la serie', fr: 'Ordre de la série' })} - {seriesItemTitles.length > 0 && ` (${seriesItemTitles.length})`}:{' '} - - {seriesItemTitles.length === 0 ? ( - - {t({ - en: 'No items in this series.', - es: 'No hay elementos en esta serie.', - fr: 'Aucun élément dans cette série.' - })} - - ) : ( -
    - {seriesItemTitles.map((title, index) => ( -
  1. - {title} -
  2. - ))} -
- )} -
- )} - {item.authors && item.authors.length > 0 && ( -
- {t({ en: 'Authors', es: 'Autores', fr: 'Auteurs' })}: - {item.authors.join(', ')} -
- )} - {item.internal && ( -
- {t({ en: 'Edition', es: 'Edición', fr: 'Édition' })}: - {item.internal.edition} -
- )} -
- {t({ en: 'Source', es: 'Origen', fr: 'Source' })}: - - {item.source.kind === 'repo' - ? item.source.name - : t({ - en: 'No repo; it was manually added to the platform', - es: 'Sin repositorio; se agregó manualmente a la plataforma', - fr: 'Aucun dépôt ; ajouté manuellement à la plateforme' - })} - -
- {item.createdAt && ( -
- {t({ en: 'Added', es: 'Agregado el', fr: 'Ajouté le' })}: - {toBasicISOString(item.createdAt)} -
- )} - {item.availability && ( -
- - {t({ en: 'Available to', es: 'Disponible para', fr: 'Disponible pour' })}:{' '} - - - {item.availability.kind === 'all' - ? t({ en: 'All groups', es: 'Todos los grupos', fr: 'Tous les groupes' }) - : (item.availability.name ?? t({ en: 'Another group', es: 'Otro grupo', fr: 'Un autre groupe' }))} - -
- )} -
-
- -
-
- )} -
-
- ); -}; - const CreateSeriesInstrumentDialog = ({ existingTitles, forms, @@ -1075,6 +886,11 @@ const RouteComponent = () => { const visibleIds = new Set(); for (const instrument of availableInstruments) { + // An archived series is left out of the list but not the group's selection: `hiddenAccessibleIds` + // carries it through a save, so unarchiving restores it here without the group opting back in. + if (instrument.kind === 'SERIES' && instrument.archivedAt) { + continue; + } const repoId = instrument.sourceRepo?.id ?? null; // Show an instrument if it was uploaded manually, comes from a repo currently assigned to this // group, or has already been selected by the group (so selections survive repo removal). diff --git a/apps/web/src/routes/_app/group/remote-assignments.tsx b/apps/web/src/routes/_app/group/remote-assignments.tsx index bfd91e9e9..395d044d3 100644 --- a/apps/web/src/routes/_app/group/remote-assignments.tsx +++ b/apps/web/src/routes/_app/group/remote-assignments.tsx @@ -14,6 +14,7 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { setupStateQueryOptions, useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery'; import { useAppStore } from '@/store'; +import { selectAdministrableInstruments } from '@/utils/administrable-instruments'; import { getDefaultAssignmentExpiry } from '@/utils/assignment-duration'; type Mode = 'CREATE' | 'DELETE' | 'LANDING'; @@ -74,9 +75,10 @@ const RouteComponent = () => { return null; } - const instruments = (instrumentInfoQuery.data ?? []) - .filter((instrument) => currentGroup.accessibleInstrumentIds.includes(instrument.id)) - .map((instrument) => ({ id: instrument.id, title: instrument.details.title })); + // The API enforces the same rule, so an instrument missing here would be refused there anyway. + const instruments = selectAdministrableInstruments(instrumentInfoQuery.data ?? [], currentGroup).map( + (instrument) => ({ id: instrument.id, title: instrument.details.title }) + ); return ( diff --git a/apps/web/src/routes/_app/instruments/accessible-instruments.tsx b/apps/web/src/routes/_app/instruments/accessible-instruments.tsx index 0fcd1bb4d..b486bb7ba 100644 --- a/apps/web/src/routes/_app/instruments/accessible-instruments.tsx +++ b/apps/web/src/routes/_app/instruments/accessible-instruments.tsx @@ -9,6 +9,7 @@ import { PageHeader } from '@/components/PageHeader'; import { WithFallback } from '@/components/WithFallback'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useAppStore } from '@/store'; +import { selectAdministrableInstruments } from '@/utils/administrable-instruments'; const RouteComponent = () => { const currentGroup = useAppStore((store) => store.currentGroup); @@ -31,11 +32,7 @@ const RouteComponent = () => { { - return currentGroup.accessibleInstrumentIds.includes(instrument.id); - }) - : instrumentInfoQuery.data, + data: instrumentInfoQuery.data && selectAdministrableInstruments(instrumentInfoQuery.data, currentGroup), onSelect: (instrument) => { void navigate({ params: { id: instrument.id }, diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx index 51e7a18f4..68a9abe29 100644 --- a/apps/web/src/routes/_app/session/remote-assignment.tsx +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -18,6 +18,7 @@ import { useCreateAssignment } from '@/hooks/useCreateAssignment'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useAppStore } from '@/store'; +import { selectAdministrableInstruments } from '@/utils/administrable-instruments'; import { getDefaultAssignmentExpiry } from '@/utils/assignment-duration'; /** Slide-over panel shown after an assignment is created, displaying the URL, copy button, and QR code */ @@ -126,11 +127,7 @@ const RouteComponent = () => { { - return currentGroup.accessibleInstrumentIds.includes(instrument.id); - }) - : instrumentInfoQuery.data, + data: instrumentInfoQuery.data && selectAdministrableInstruments(instrumentInfoQuery.data, currentGroup), onSelect: (instrument) => { setSelectedInstrument(instrument); setIsCreateModalOpen(true); diff --git a/apps/web/src/translations/common.json b/apps/web/src/translations/common.json index ca33254a9..f21e1fa04 100644 --- a/apps/web/src/translations/common.json +++ b/apps/web/src/translations/common.json @@ -14,6 +14,11 @@ "es": "Administrador", "fr": "Administrateur" }, + "archive": { + "en": "Archive", + "es": "Archivar", + "fr": "Archiver" + }, "assignment": { "en": "Assignment", "es": "Asignación", @@ -302,6 +307,11 @@ "es": "Hora de recopilación", "fr": "Horodatage" }, + "unarchive": { + "en": "Unarchive", + "es": "Desarchivar", + "fr": "Désarchiver" + }, "update": { "en": "Update", "es": "Actualizar", diff --git a/apps/web/src/utils/__tests__/administrable-instruments.test.ts b/apps/web/src/utils/__tests__/administrable-instruments.test.ts new file mode 100644 index 000000000..b4aac66da --- /dev/null +++ b/apps/web/src/utils/__tests__/administrable-instruments.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { selectAdministrableInstruments } from '../administrable-instruments'; + +const archivedSeries = { archivedAt: new Date('2024-06-01'), id: 'archived-series', kind: 'SERIES' as const }; +const activeSeries = { archivedAt: null, id: 'active-series', kind: 'SERIES' as const }; +const form = { id: 'form', kind: 'FORM' as const }; + +const ids = (instruments: { id: string }[]) => instruments.map(({ id }) => id); + +describe('selectAdministrableInstruments', () => { + it('should drop an archived series the group has opted into, since archiving retires it from new sessions', () => { + const group = { accessibleInstrumentIds: ['archived-series', 'active-series', 'form'] }; + expect(ids(selectAdministrableInstruments([archivedSeries, activeSeries, form], group))).toEqual([ + 'active-series', + 'form' + ]); + }); + + it('should keep only what the current group has opted into', () => { + expect(ids(selectAdministrableInstruments([activeSeries, form], { accessibleInstrumentIds: ['form'] }))).toEqual([ + 'form' + ]); + }); + + it('should drop an archived series even without a current group, which otherwise admits everything', () => { + expect(ids(selectAdministrableInstruments([archivedSeries, activeSeries, form], null))).toEqual([ + 'active-series', + 'form' + ]); + }); +}); diff --git a/apps/web/src/utils/__tests__/instrument-editions.test.ts b/apps/web/src/utils/__tests__/instrument-editions.test.ts new file mode 100644 index 000000000..2ebff39b1 --- /dev/null +++ b/apps/web/src/utils/__tests__/instrument-editions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { selectLatestEditions } from '../instrument-editions'; + +const instrument = (id: string, name: string, edition: number) => ({ id, internal: { edition, name } }); + +describe('selectLatestEditions', () => { + it('should keep only the highest edition of each instrument, whatever order the editions arrive in', () => { + const result = selectLatestEditions([ + instrument('hq-2', 'HQ', 2), + instrument('hq-1', 'HQ', 1), + instrument('bdi-1', 'BDI', 1) + ]); + expect(result.map(({ id }) => id)).toEqual(['hq-2', 'bdi-1']); + }); +}); diff --git a/apps/web/src/utils/__tests__/series-overview.test.ts b/apps/web/src/utils/__tests__/series-overview.test.ts new file mode 100644 index 000000000..099324dbc --- /dev/null +++ b/apps/web/src/utils/__tests__/series-overview.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { sortSeriesOverviewRows } from '../series-overview'; + +const titles = (rows: { title: string }[]) => rows.map(({ title }) => title); + +describe('sortSeriesOverviewRows', () => { + it('should list archived series after every active one, so what is still in use comes first', () => { + const rows = [ + { archivedAt: new Date('2024-06-01'), groupName: 'Alpha', title: 'Retired' }, + { archivedAt: null, groupName: 'Zeta', title: 'Current' } + ]; + expect(titles(sortSeriesOverviewRows(rows))).toEqual(['Current', 'Retired']); + }); + + it('should keep each group together, with the series every group shares ahead of any group', () => { + const rows = [ + { groupName: 'Psychosis Lab', title: 'A' }, + { groupName: null, title: 'Shared' }, + { groupName: 'Depression Clinic', title: 'B' } + ]; + expect(titles(sortSeriesOverviewRows(rows))).toEqual(['Shared', 'B', 'A']); + }); + + it('should order a group series by title', () => { + const rows = [ + { groupName: 'Depression Clinic', title: 'Intake' }, + { groupName: 'Depression Clinic', title: 'Follow-up' } + ]; + expect(titles(sortSeriesOverviewRows(rows))).toEqual(['Follow-up', 'Intake']); + }); +}); diff --git a/apps/web/src/utils/administrable-instruments.ts b/apps/web/src/utils/administrable-instruments.ts new file mode 100644 index 000000000..f9fecfb6f --- /dev/null +++ b/apps/web/src/utils/administrable-instruments.ts @@ -0,0 +1,27 @@ +import type { InstrumentKind } from '@opendatacapture/runtime-core'; + +type AdministrableCandidate = { + archivedAt?: Date | null; + id: string; + kind: InstrumentKind; +}; + +/** + * The instruments that may be started or assigned now: those the current group has opted into, less + * any series an administrator has archived. Without a current group, every instrument qualifies. Only + * pickers for new sessions and assignments filter this way — anything showing collected data must + * keep archived series, whose records are unaffected. + */ +function selectAdministrableInstruments( + instruments: TInstrument[], + currentGroup: null | { accessibleInstrumentIds: string[] } +): TInstrument[] { + return instruments.filter((instrument) => { + if (instrument.kind === 'SERIES' && instrument.archivedAt) { + return false; + } + return !currentGroup || currentGroup.accessibleInstrumentIds.includes(instrument.id); + }); +} + +export { selectAdministrableInstruments }; diff --git a/apps/web/src/utils/instrument-editions.ts b/apps/web/src/utils/instrument-editions.ts new file mode 100644 index 000000000..6b6c44278 --- /dev/null +++ b/apps/web/src/utils/instrument-editions.ts @@ -0,0 +1,17 @@ +type EditionedInstrument = { + internal: { edition: number; name: string }; +}; + +/** The highest edition of each instrument, in the order each name first appears. */ +function selectLatestEditions(instruments: TInstrument[]): TInstrument[] { + const latestByName = new Map(); + for (const instrument of instruments) { + const current = latestByName.get(instrument.internal.name); + if (!current || instrument.internal.edition > current.internal.edition) { + latestByName.set(instrument.internal.name, instrument); + } + } + return [...latestByName.values()]; +} + +export { selectLatestEditions }; diff --git a/apps/web/src/utils/series-overview.ts b/apps/web/src/utils/series-overview.ts new file mode 100644 index 000000000..c0acba16a --- /dev/null +++ b/apps/web/src/utils/series-overview.ts @@ -0,0 +1,23 @@ +type SeriesOverviewRow = { + archivedAt?: Date | null; + groupName: null | string; + title: string; +}; + +/** + * Active series before archived ones; within each, grouped by owner with the series every group shares + * first, then alphabetically by title. + */ +function sortSeriesOverviewRows(rows: TRow[]): TRow[] { + return rows.toSorted( + (a, b) => + Number(Boolean(a.archivedAt)) - Number(Boolean(b.archivedAt)) || + Number(a.groupName !== null) - Number(b.groupName !== null) || + (a.groupName ?? '').localeCompare(b.groupName ?? '') || + a.title.localeCompare(b.title) + ); +} + +export type { SeriesOverviewRow }; + +export { sortSeriesOverviewRows }; diff --git a/packages/schemas/src/audit/audit.ts b/packages/schemas/src/audit/audit.ts index 26ca10dd2..7cabea308 100644 --- a/packages/schemas/src/audit/audit.ts +++ b/packages/schemas/src/audit/audit.ts @@ -4,7 +4,7 @@ import { $Group } from '../group/group.js'; import { $User } from '../user/user.js'; export type $AuditLogAction = z.infer; -export const $AuditLogAction = z.enum(['CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'SEND_EMAIL']); +export const $AuditLogAction = z.enum(['CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'SEND_EMAIL', 'ARCHIVE', 'UNARCHIVE']); export type $AuditLogEntity = z.infer; export const $AuditLogEntity = z.enum([ diff --git a/packages/schemas/src/instrument/instrument.base.ts b/packages/schemas/src/instrument/instrument.base.ts index 37b1c357b..927819c2d 100644 --- a/packages/schemas/src/instrument/instrument.base.ts +++ b/packages/schemas/src/instrument/instrument.base.ts @@ -252,6 +252,9 @@ type ScalarInstrumentInfo = BaseInstr /** Info for a series instrument, which bundles the scalar instruments referenced by `seriesItems`. */ type SeriesInstrumentInfo = BaseInstrumentInfo & { + // When an administrator retired this series from new sessions and assignments, or null while it is + // active. Records already collected with it are unaffected, so readers of data must not filter on it. + archivedAt?: Date | null; kind: 'SERIES'; // The group that created and owns this series, or null for a series shared across every group (one // uploaded directly, or created before series became group-owned). Only the owning group may delete @@ -280,6 +283,7 @@ const $ScalarInstrumentInfo = $BaseInstrumentInfo.extend({ }) satisfies z.ZodType; const $SeriesInstrumentInfo = $BaseInstrumentInfo.extend({ + archivedAt: z.coerce.date().nullish(), kind: z.literal('SERIES'), seriesGroupId: z.string().nullish(), seriesItems: z.object({ id: z.string() }).array() @@ -340,6 +344,18 @@ type CreateSeriesInstrumentResult = | { existingTitle: NonNullable; outcome: 'duplicate' } | { instrumentId: string; outcome: 'created' }; +/** A series as the administrators' overview lists it: its info, plus the name of the group that owns it. */ +type SeriesInstrumentOverview = z.infer; +const $SeriesInstrumentOverview = $SeriesInstrumentInfo.extend({ + // Null for a series shared across every group, matching a null `seriesGroupId`. + seriesGroup: z.object({ id: z.string(), name: z.string() }).nullable() +}); + +type $UpdateSeriesInstrumentData = z.infer; +const $UpdateSeriesInstrumentData = z.object({ + isArchived: z.boolean() +}); + const $BaseInstrumentBundleContainer = z.object({ id: z.string() }); @@ -384,8 +400,11 @@ export { $ScalarInstrument, $ScalarInstrumentBundleContainer, $ScalarInstrumentInternal, + $SeriesInstrumentInfo, + $SeriesInstrumentOverview, $UnilingualInstrumentDetails, - $UnilingualScalarInstrument + $UnilingualScalarInstrument, + $UpdateSeriesInstrumentData }; export type { @@ -397,6 +416,7 @@ export type { ScalarInstrumentInfo, SeriesInstrumentBundleContainer, SeriesInstrumentInfo, + SeriesInstrumentOverview, TranslatedInstrumentInfo, UnilingualInstrumentInfo }; diff --git a/testing/src/pages/_app/admin/instruments.page.ts b/testing/src/pages/_app/admin/instruments.page.ts new file mode 100644 index 000000000..799b5ab9f --- /dev/null +++ b/testing/src/pages/_app/admin/instruments.page.ts @@ -0,0 +1,40 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AppPage } from '../route.page'; + +export class AdminInstrumentsPage extends AppPage { + readonly archiveDialog: Locator; + readonly confirmArchive: Locator; + readonly searchBar: Locator; + + constructor(page: Page) { + super(page); + this.archiveDialog = page.getByTestId('archive-series-dialog'); + this.confirmArchive = page.getByTestId('confirm-archive-series'); + this.searchBar = page.getByTestId('data-table-search-bar').locator('input'); + } + + /** Picks a row action (`Preview`, `Archive`, `Unarchive`) from the menu of the row with this title. */ + async chooseRowAction(title: string, action: string): Promise { + await this.row(title).getByTestId('row-actions-trigger').click(); + await this.$ref.getByRole('menuitem', { name: action }).click(); + } + + /** Switches to the series view; its nav button shares the forms view's URL and so its test id. */ + async openSeriesView(): Promise { + await this.$ref.goto('/admin/instruments?view=series'); + } + + /** + * A row of the active tab's table, found by the exact title of one of its cells, each of which carries + * its full text as a tooltip; the table has no per-row test id of its own. + */ + row(title: string): Locator { + return this.$ref.getByTestId('data-table-row').filter({ has: this.$ref.getByTitle(title, { exact: true }) }); + } + + /** A series' status; it carries `data-archived="true"` once the series is archived. */ + seriesStatus(title: string): Locator { + return this.row(title).getByTestId('series-status'); + } +} diff --git a/testing/src/specs/admin-instruments.spec.ts b/testing/src/specs/admin-instruments.spec.ts new file mode 100644 index 000000000..79a3c10ee --- /dev/null +++ b/testing/src/specs/admin-instruments.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from '../support/fixtures'; + +const API = '/api/v1'; + +test.describe('admin instruments', () => { + test.use({ actingRole: 'ADMIN' }); + + test('should preview an instrument when its row is double-clicked', async ({ getPageModel, page }) => { + const title = 'Happiness Questionnaire'; + const instrumentsPage = await getPageModel('/admin/instruments'); + await instrumentsPage.searchBar.fill(title); + await instrumentsPage.row(title).dblclick(); + await expect(page.getByRole('dialog')).toContainText(title); + }); + + test('should reach the series view from the Instruments submenu in the sidebar', async ({ getPageModel, page }) => { + await getPageModel('/admin/instruments'); + const sidebar = page.getByTestId('sidebar'); + await sidebar.getByRole('button', { exact: true, name: 'Instruments' }).click(); + await sidebar.getByRole('button', { exact: true, name: 'Series' }).click(); + await expect(page).toHaveURL(/\/admin\/instruments\?view=series/); + await expect(page.getByTestId('page-header')).toContainText('Series Instruments'); + }); + + test('should find a series by searching, listed under the name of the group that owns it', async ({ + api, + getPageModel, + uniqueId + }) => { + const group = await api.createGroup({ name: `SeriesOwner${uniqueId}` }); + const title = `Series ${uniqueId}`; + await api.createSeries(group.id, title); + + const seriesPage = await getPageModel('/admin/instruments'); + await seriesPage.openSeriesView(); + await seriesPage.searchBar.fill(title); + + await expect(seriesPage.row(title)).toContainText(`SeriesOwner${uniqueId}`); + }); + + test('should archive a series, so no new assignment of it is accepted, and unarchive it again', async ({ + adminToken, + api, + apiRequestContext, + getPageModel, + uniqueId + }) => { + const group = await api.createGroup({ name: `SeriesArchive${uniqueId}` }); + const title = `Series ${uniqueId}`; + const seriesId = await api.createSeries(group.id, title); + const subjectId = await api.createSubject(group.id); + + const seriesPage = await getPageModel('/admin/instruments'); + await seriesPage.openSeriesView(); + await seriesPage.searchBar.fill(title); + await seriesPage.chooseRowAction(title, 'Archive'); + await expect(seriesPage.archiveDialog).toContainText(`SeriesArchive${uniqueId}`); + await seriesPage.confirmArchive.click(); + await expect(seriesPage.seriesStatus(title)).toHaveAttribute('data-archived', 'true'); + + const assignment = await apiRequestContext.post(`${API}/assignments`, { + data: { expiresAt: new Date(Date.now() + 86_400_000), groupId: group.id, instrumentId: seriesId, subjectId }, + headers: { Authorization: `Bearer ${adminToken}` } + }); + expect(assignment.status(), await assignment.text()).toBe(422); + + await seriesPage.chooseRowAction(title, 'Unarchive'); + await expect(seriesPage.seriesStatus(title)).toHaveAttribute('data-archived', 'false'); + }); + + test('should archive an active series on a double-click, through the confirmation, and unarchive an archived one', async ({ + api, + getPageModel, + uniqueId + }) => { + const group = await api.createGroup({ name: `SeriesDoubleClick${uniqueId}` }); + const title = `Series ${uniqueId}`; + await api.createSeries(group.id, title); + + const seriesPage = await getPageModel('/admin/instruments'); + await seriesPage.openSeriesView(); + await seriesPage.searchBar.fill(title); + await seriesPage.row(title).dblclick(); + await seriesPage.confirmArchive.click(); + await expect(seriesPage.seriesStatus(title)).toHaveAttribute('data-archived', 'true'); + + await seriesPage.row(title).dblclick(); + await expect(seriesPage.seriesStatus(title)).toHaveAttribute('data-archived', 'false'); + }); +}); diff --git a/testing/src/support/api-client.ts b/testing/src/support/api-client.ts index 16325e320..4200226eb 100644 --- a/testing/src/support/api-client.ts +++ b/testing/src/support/api-client.ts @@ -64,6 +64,22 @@ export class ApiClient { return group; } + /** Assembles a series owned by the group out of the first two forms available, returning its id. */ + async createSeries(groupId: string, title: string): Promise { + const items = (await this.getInstrumentInfo()) + .flatMap((info) => (info.kind === 'FORM' ? [{ edition: info.internal.edition, name: info.internal.name }] : [])) + .slice(0, 2); + const result = await this.expectJson<{ instrumentId: string; outcome: 'created' }>( + this.request.post(`${API}/instruments/series`, { + data: { confirmDuplicate: true, details: { title }, groupId, items, language: 'en' }, + headers: this.authHeaders + }), + 201, + 'create series' + ); + return result.instrumentId; + } + /** * Creates a session, and with it the subject it names. A subject seeded this way holds no * instrument records, which is what distinguishes it under the "with records only" filter. diff --git a/testing/src/support/fixtures.ts b/testing/src/support/fixtures.ts index dd6757fd3..a995c353f 100644 --- a/testing/src/support/fixtures.ts +++ b/testing/src/support/fixtures.ts @@ -10,6 +10,7 @@ import { AuditLogsPage } from '../pages/_app/admin/audit/logs.page'; import { BrandingPage } from '../pages/_app/admin/branding/index.page'; import { BrandingLoginPagePage } from '../pages/_app/admin/branding/login-page.page'; import { InstrumentReposPage } from '../pages/_app/admin/instrument-repos/index.page'; +import { AdminInstrumentsPage } from '../pages/_app/admin/instruments.page'; import { MailSettingsPage } from '../pages/_app/admin/mail.page'; import { AdminSettingsPage } from '../pages/_app/admin/settings.page'; import { AdminUserPage } from '../pages/_app/admin/users/$userId.page'; @@ -44,6 +45,7 @@ const pageModels = { '/admin/branding': BrandingPage, '/admin/branding/login-page': BrandingLoginPagePage, '/admin/instrument-repos': InstrumentReposPage, + '/admin/instruments': AdminInstrumentsPage, '/admin/mail': MailSettingsPage, '/admin/settings': AdminSettingsPage, '/admin/users/$userId': AdminUserPage, From dd820d8cef2c494df72fd909bd9267dad04196f0 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 1 Oct 2026 23:56:44 -0400 Subject: [PATCH 2/3] fix(web): open /admin/instruments without rewriting its address The `view` search param defaulted to `forms`, so the router rewrote a bare `/admin/instruments` to `?view=forms`. It is now optional, and a missing or unknown view still shows the forms view. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/routes/_app/admin/instruments.tsx | 4 ++-- .../__tests__/admin-instruments-search.test.ts | 17 +++++++++++++++++ apps/web/src/utils/admin-instruments-search.ts | 6 ++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/utils/__tests__/admin-instruments-search.test.ts create mode 100644 apps/web/src/utils/admin-instruments-search.ts diff --git a/apps/web/src/routes/_app/admin/instruments.tsx b/apps/web/src/routes/_app/admin/instruments.tsx index 6bf3f6c2d..e5c4b21e3 100644 --- a/apps/web/src/routes/_app/admin/instruments.tsx +++ b/apps/web/src/routes/_app/admin/instruments.tsx @@ -8,7 +8,6 @@ import { cn } from '@douglasneuroinformatics/libui/utils'; import { translateInstrumentInfo } from '@opendatacapture/instrument-utils'; import type { InstrumentInfo, TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod/v4'; import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog'; @@ -20,6 +19,7 @@ import { useSeriesInstrumentsOverviewQuery } from '@/hooks/useSeriesInstrumentsOverviewQuery'; import { useUpdateSeriesInstrumentArchiveMutation } from '@/hooks/useUpdateSeriesInstrumentArchiveMutation'; +import { $AdminInstrumentsSearch } from '@/utils/admin-instruments-search'; import { selectLatestEditions } from '@/utils/instrument-editions'; import { sortSeriesOverviewRows } from '@/utils/series-overview'; import type { SeriesOverviewRow } from '@/utils/series-overview'; @@ -400,5 +400,5 @@ export const Route = createFileRoute('/_app/admin/instruments')({ context.queryClient.ensureQueryData(groupsQueryOptions()) ]); }, - validateSearch: z.object({ view: z.enum(['forms', 'series']).catch('forms') }) + validateSearch: $AdminInstrumentsSearch }); diff --git a/apps/web/src/utils/__tests__/admin-instruments-search.test.ts b/apps/web/src/utils/__tests__/admin-instruments-search.test.ts new file mode 100644 index 000000000..97475e634 --- /dev/null +++ b/apps/web/src/utils/__tests__/admin-instruments-search.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { $AdminInstrumentsSearch } from '../admin-instruments-search'; + +describe('$AdminInstrumentsSearch', () => { + it('should leave the view unset for a bare address, so the router does not rewrite it to add one', () => { + expect($AdminInstrumentsSearch.parse({})).toEqual({ view: undefined }); + }); + + it('should keep a requested view', () => { + expect($AdminInstrumentsSearch.parse({ view: 'series' })).toEqual({ view: 'series' }); + }); + + it('should drop an unknown view rather than fail, so a mistyped link still opens the forms view', () => { + expect($AdminInstrumentsSearch.parse({ view: 'nonsense' })).toEqual({ view: undefined }); + }); +}); diff --git a/apps/web/src/utils/admin-instruments-search.ts b/apps/web/src/utils/admin-instruments-search.ts new file mode 100644 index 000000000..3fe8bd838 --- /dev/null +++ b/apps/web/src/utils/admin-instruments-search.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; + +export type AdminInstrumentsSearch = z.infer; +// Optional rather than defaulted, so a bare `/admin/instruments` opens the forms view without the router +// rewriting the address to add `?view=forms`. +export const $AdminInstrumentsSearch = z.object({ view: z.enum(['forms', 'series']).optional().catch(undefined) }); From e938a6420355210026bcde8b2b617d9160d8068e Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 1 Oct 2026 23:56:45 -0400 Subject: [PATCH 3/3] test(testing): expand the Admin Panel and match row actions exactly The sidebar test clicked Instruments while the Admin Panel section was still collapsed, and picking the `Archive` row action also matched `Unarchive`. Co-Authored-By: Claude Opus 5.5 --- testing/src/pages/_app/admin/instruments.page.ts | 3 ++- testing/src/specs/admin-instruments.spec.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/testing/src/pages/_app/admin/instruments.page.ts b/testing/src/pages/_app/admin/instruments.page.ts index 799b5ab9f..8d41918eb 100644 --- a/testing/src/pages/_app/admin/instruments.page.ts +++ b/testing/src/pages/_app/admin/instruments.page.ts @@ -17,7 +17,8 @@ export class AdminInstrumentsPage extends AppPage { /** Picks a row action (`Preview`, `Archive`, `Unarchive`) from the menu of the row with this title. */ async chooseRowAction(title: string, action: string): Promise { await this.row(title).getByTestId('row-actions-trigger').click(); - await this.$ref.getByRole('menuitem', { name: action }).click(); + // Exact, since `Archive` is otherwise also a substring of `Unarchive`. + await this.$ref.getByRole('menuitem', { exact: true, name: action }).click(); } /** Switches to the series view; its nav button shares the forms view's URL and so its test id. */ diff --git a/testing/src/specs/admin-instruments.spec.ts b/testing/src/specs/admin-instruments.spec.ts index 79a3c10ee..092de17db 100644 --- a/testing/src/specs/admin-instruments.spec.ts +++ b/testing/src/specs/admin-instruments.spec.ts @@ -16,6 +16,8 @@ test.describe('admin instruments', () => { test('should reach the series view from the Instruments submenu in the sidebar', async ({ getPageModel, page }) => { await getPageModel('/admin/instruments'); const sidebar = page.getByTestId('sidebar'); + // The admin links sit in the Admin Panel section, which starts collapsed even on an admin page. + await sidebar.getByRole('button', { exact: true, name: 'Admin Panel' }).click(); await sidebar.getByRole('button', { exact: true, name: 'Instruments' }).click(); await sidebar.getByRole('button', { exact: true, name: 'Series' }).click(); await expect(page).toHaveURL(/\/admin\/instruments\?view=series/);