Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ jobs:
git config --global --add safe.directory /__w/OpenDataCapture/OpenDataCapture
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright
run: pnpm --filter "@opendatacapture/e2e" exec playwright install --with-deps
# Run lint and unit tests before the (occasionally flaky) Playwright install so those results
# aren't blocked by a hung browser download.
- name: Lint
run: pnpm lint
- name: Unit Tests
run: pnpm test
- name: Install Playwright
# Cap the step so a hang fails fast instead of hitting the 6h job limit, skip the interactive
# apt prompt, and install only the browsers the e2e suite uses.
timeout-minutes: 10
env:
DEBIAN_FRONTEND: noninteractive
run: pnpm --filter "@opendatacapture/e2e" exec playwright install --with-deps chromium firefox
- name: End-to-End Tests
run: pnpm --filter "@opendatacapture/e2e" test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ storybook-static

# playwright
testing/e2e/.playwright

# tanstack router generated temp files
.tanstack
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@nestjs/swagger": "^11.0.6",
"@nestjs/throttler": "^6.5.0",
"@opendatacapture/demo": "workspace:*",
"@opendatacapture/instrument-bundler": "workspace:*",
"@opendatacapture/instrument-library": "workspace:*",
"@opendatacapture/instrument-utils": "workspace:*",
"@opendatacapture/release-info": "workspace:*",
Expand All @@ -46,6 +47,7 @@
"axios": "catalog:",
"express": "^5.0.1",
"fastify": "^5.7.1",
"jszip": "^3.10.1",
"lodash-es": "workspace:lodash-es__4.x@*",
"mongodb": "^6.15.0",
"msgpackr": "catalog:",
Expand Down
46 changes: 38 additions & 8 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ model Group {
auditLogs AuditLog[]
instrumentRecords InstrumentRecord[]
instrumentRecordFiles InstrumentRecordFile[]
instrumentRepoIds String[] @db.ObjectId
instrumentRepos InstrumentRepo[] @relation(fields: [instrumentRepoIds], references: [id])
name String @unique
settings GroupSettings
sessions Session[]
Expand Down Expand Up @@ -180,18 +182,45 @@ type InstrumentInternal {
}

model Instrument {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @map("_id")
assignments Assignment[]
bundle String
groups Group[] @relation(fields: [groupIds], references: [id])
groupIds String[] @db.ObjectId
records InstrumentRecord[]
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @map("_id")
assignments Assignment[]
bundle String
groups Group[] @relation(fields: [groupIds], references: [id])
groupIds String[] @db.ObjectId
records InstrumentRecord[]
// Set only when the instrument was first created by importing an instrument repository. Left null
// for manually-uploaded instruments, which keep "manual" provenance even if a repo also provides them.
sourceRepoId String? @db.ObjectId
// Denormalized repository name, kept so the source tag survives deletion of the repository.
sourceRepoName String?

@@map("InstrumentModel")
}

// Instrument Repos

model InstrumentRepo {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
// Display label only (derived from the repo name); not unique because two owners may have repos with
// the same name (e.g. alice/forms and bob/forms). `url` is the true identity and is unique.
name String
owner String
repoName String
url String @unique
instrumentIds String[]
groupIds String[] @db.ObjectId
groups Group[] @relation(fields: [groupIds], references: [id])
lastSyncedAt DateTime? @db.Date
// AES-256-GCM encrypted GitHub personal access token, used to access private repositories.
accessToken String?

@@map("InstrumentRepoModel")
}

// Subjects

enum Sex {
Expand Down Expand Up @@ -228,6 +257,7 @@ enum AppSubject {
Group
Instrument
InstrumentRecord
InstrumentRepo
Session
Subject
User
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/auth/ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class AbilityFactory {
ability.can('manage', 'Assignment', { groupId: { in: groupIds } });
ability.can('manage', 'Group', { id: { in: groupIds } });
ability.can('read', 'Instrument');
ability.can('read', 'InstrumentRepo', { groupIds: { hasSome: groupIds } });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't standard user also need to be able to read this?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise, could cause problems. Did you verify everything works for standard users?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — standard users don't need read InstrumentRepo. The provenance shown in the UI (sourceRepo) is denormalized onto each Instrument record and served via /instruments/info, which is gated only by read Instrument (standard users have that unconstrained). buildInstrumentSourceMap reads sourceRepoId/sourceRepoName straight off the Instrument — it never queries InstrumentRepo or checks that ability. The /instrument-repos endpoints are consumed solely by the two admin-only pages (useInstrumentReposQuery is imported only there), so standard users never hit them. I checked the manage-group / datahub / accessible-instruments flows and they work for standard users. Granting standard users repo read would just be over-permissive, so I left the ability as-is.

ability.can('create', 'InstrumentRecord');
ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } });
ability.can('read', 'InstrumentRecord', { groupId: { in: groupIds } });
Expand Down
43 changes: 38 additions & 5 deletions apps/api/src/groups/__tests__/groups.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,25 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it } from 'vitest';

import { InstrumentsService } from '../../instruments/instruments.service';
import { GroupsService } from '../groups.service';

describe('GroupsService', () => {
let groupsService: GroupsService;
let groupModel: MockedInstance<Model<'Group'>>;
let instrumentsService: MockedInstance<InstrumentsService>;
let instrumentModel: MockedInstance<Model<'Instrument'>>;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
GroupsService,
MockFactory.createForModelToken(getModelToken('Group')),
MockFactory.createForService(InstrumentsService)
MockFactory.createForModelToken(getModelToken('Instrument'))
]
}).compile();
groupModel = moduleRef.get(getModelToken('Group'));
instrumentModel = moduleRef.get(getModelToken('Instrument'));
groupsService = moduleRef.get(GroupsService);
instrumentsService = moduleRef.get(InstrumentsService);
instrumentsService.find.mockResolvedValue([]);
instrumentModel.findMany.mockResolvedValue([]);
});

describe('create', () => {
Expand All @@ -34,6 +33,15 @@ describe('GroupsService', () => {
expect(groupModel.create.mock.lastCall?.[0]).toMatchObject({ data: { name: 'Test Group' } });
});

it('should connect only non-repo instruments (sourceRepoId: null)', async () => {
instrumentModel.findMany.mockResolvedValueOnce([{ id: 'manual-1' }, { id: 'manual-2' }]);
await groupsService.create({ name: 'Test Group', type: 'CLINICAL' });
expect(instrumentModel.findMany).toHaveBeenCalledWith({ where: { sourceRepoId: null } });
expect(groupModel.create.mock.lastCall?.[0]).toMatchObject({
data: { accessibleInstruments: { connect: [{ id: 'manual-1' }, { id: 'manual-2' }] } }
});
});

it('should throw a ConflictException if a group with the same name already exists in the db', async () => {
groupModel.exists.mockResolvedValueOnce(true);
await expect(groupsService.create({ name: 'Test Group', type: 'CLINICAL' })).rejects.toBeInstanceOf(
Expand All @@ -42,6 +50,31 @@ describe('GroupsService', () => {
});
});

describe('updateById', () => {
it('should set the instrumentRepos relation from instrumentRepoIds', async () => {
groupModel.findFirst.mockResolvedValueOnce({ name: 'Test Group', settings: {} });
await groupsService.updateById('123', { instrumentRepoIds: ['repo-1', 'repo-2'] });
expect(groupModel.update.mock.lastCall?.[0]).toMatchObject({
data: { instrumentRepos: { set: [{ id: 'repo-1' }, { id: 'repo-2' }] } }
});
});

it('should not throw when the name is unchanged', async () => {
groupModel.findFirst.mockResolvedValueOnce({ name: 'Test Group', settings: {} });
await groupsService.updateById('123', { name: 'Test Group' });
// The current name must not be treated as a collision with itself.
expect(groupModel.exists).not.toHaveBeenCalled();
expect(groupModel.update).toHaveBeenCalled();
});

it('should throw a ConflictException when renaming to an existing name', async () => {
groupModel.findFirst.mockResolvedValueOnce({ name: 'Old Name', settings: {} });
groupModel.exists.mockResolvedValueOnce(true);
await expect(groupsService.updateById('123', { name: 'Taken Name' })).rejects.toBeInstanceOf(ConflictException);
expect(groupModel.exists).toHaveBeenCalledWith({ name: 'Taken Name' });
});
});

describe('findAll', () => {
it('should return the array returned by the group model', async () => {
groupModel.findMany.mockResolvedValueOnce([{ name: 'Test Group' }]);
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/groups/dto/update-group.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture
@ValidationSchema($UpdateGroupData)
export class UpdateGroupDto implements UpdateGroupData {
accessibleInstrumentIds?: string[];
instrumentRepoIds?: string[];
name?: string;
settings?: Partial<GroupSettings>;
type?: GroupType;
Expand Down
3 changes: 0 additions & 3 deletions apps/api/src/groups/groups.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { Module } from '@nestjs/common';

import { InstrumentsModule } from '@/instruments/instruments.module';

import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';

@Module({
controllers: [GroupsController],
exports: [GroupsService],
imports: [InstrumentsModule],
providers: [GroupsService]
})
export class GroupsModule {}
25 changes: 19 additions & 6 deletions apps/api/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { Prisma } from '@prisma/client';

import { accessibleQuery } from '@/auth/ability.utils';
import type { EntityOperationOptions } from '@/core/types';
import { InstrumentsService } from '@/instruments/instruments.service';

import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
Expand All @@ -14,18 +13,23 @@ import { UpdateGroupDto } from './dto/update-group.dto';
export class GroupsService {
constructor(
@InjectModel('Group') private readonly groupModel: Model<'Group'>,
private readonly instrumentsService: InstrumentsService
@InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'>
) {}

async create({ name, settings, type, ...data }: CreateGroupDto) {
const exists = await this.groupModel.exists({ name });
if (exists) {
throw new ConflictException(`Group with name '${name}' already exists!`);
}
// Connect only instruments that did not come from an instrument repository. Repo-sourced
// instruments are opt-in: a group manager must select them manually after a repo is assigned.
const nonRepoInstruments = await this.instrumentModel.findMany({
where: { sourceRepoId: null }
});
return this.groupModel.create({
data: {
accessibleInstruments: {
connect: (await this.instrumentsService.find()).map(({ id }) => ({ id }))
connect: nonRepoInstruments.map(({ id }) => ({ id }))
},
name,
settings: {
Expand Down Expand Up @@ -62,25 +66,34 @@ export class GroupsService {

async updateById(
id: string,
{ accessibleInstrumentIds, settings, ...data }: UpdateGroupDto,
{ accessibleInstrumentIds, instrumentRepoIds, settings, ...data }: UpdateGroupDto,
{ ability }: EntityOperationOptions = {}
) {
const where: Prisma.GroupWhereInput = { AND: [accessibleQuery(ability, 'update', 'Group')], id };
const group = await this.groupModel.findFirst({ where });
if (!group) {
throw new NotFoundException(`Failed to find group with ID: ${id}`);
}
const exists = typeof data.name === 'string' && (await this.groupModel.exists({ name: group.name }));
// Only guard against a genuine rename collision: check the requested name (not the current one,
// which would always match this same group) and skip the check when the name is unchanged.
const exists =
typeof data.name === 'string' && data.name !== group.name && (await this.groupModel.exists({ name: data.name }));
if (exists) {
throw new ConflictException(`Group with name '${group.name}' already exists!`);
throw new ConflictException(`Group with name '${data.name}' already exists!`);
}

return this.groupModel.update({
data: {
accessibleInstruments: accessibleInstrumentIds
? {
set: accessibleInstrumentIds.map((id) => ({ id }))
}
: undefined,
instrumentRepos: instrumentRepoIds
? {
set: instrumentRepoIds.map((id) => ({ id }))
}
: undefined,
settings: {
...group.settings,
...settings
Expand Down
Loading
Loading