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
43 changes: 43 additions & 0 deletions apps/api/src/assignments/__tests__/assignments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,49 @@ describe('AssignmentsService', () => {
});
});

describe('deleteBulk', () => {
it('should delete outstanding assignments from the gateway and then from the database', async () => {
assignmentModel.findMany.mockResolvedValueOnce([
{ id: 'a-1', status: 'OUTSTANDING' },
{ id: 'a-2', status: 'OUTSTANDING' }
]);
assignmentModel.delete.mockResolvedValue({});
const result = await assignmentsService.deleteBulk(['a-1', 'a-2'], { ability: permissiveUser().ability });
expect(result).toEqual({ deletedCount: 2, failedIds: [] });
expect(gatewayService.deleteRemoteAssignment).toHaveBeenCalledTimes(2);
expect(assignmentModel.delete).toHaveBeenCalledTimes(2);
});

it('should skip the gateway call for expired assignments, since their links are already dead', async () => {
assignmentModel.findMany.mockResolvedValueOnce([{ id: 'a-1', status: 'EXPIRED' }]);
assignmentModel.delete.mockResolvedValue({});
const result = await assignmentsService.deleteBulk(['a-1'], { ability: permissiveUser().ability });
expect(result).toEqual({ deletedCount: 1, failedIds: [] });
expect(gatewayService.deleteRemoteAssignment).not.toHaveBeenCalled();
expect(assignmentModel.delete).toHaveBeenCalledTimes(1);
});

it('should report a failed assignment without stopping the rest of the batch', async () => {
assignmentModel.findMany.mockResolvedValueOnce([
{ id: 'a-1', status: 'OUTSTANDING' },
{ id: 'a-2', status: 'OUTSTANDING' }
]);
gatewayService.deleteRemoteAssignment.mockRejectedValueOnce(new Error('gateway down'));
assignmentModel.delete.mockResolvedValue({});
const result = await assignmentsService.deleteBulk(['a-1', 'a-2'], { ability: permissiveUser().ability });
expect(result.deletedCount).toBe(1);
expect(result.failedIds).toEqual(['a-1']);
});

it('should only find assignments with OUTSTANDING or EXPIRED status', async () => {
assignmentModel.findMany.mockResolvedValueOnce([]);
await assignmentsService.deleteBulk(['a-1'], { ability: permissiveUser().ability });
expect(assignmentModel.findMany.mock.lastCall?.[0]).toMatchObject({
where: { id: { in: ['a-1'] }, status: { in: ['OUTSTANDING', 'EXPIRED'] } }
});
});
});

describe('updateById', () => {
it('should refuse an assignment the caller cannot update before deleting it on the gateway, which cannot be undone', async () => {
assignmentModel.exists.mockResolvedValueOnce(false);
Expand Down
26 changes: 23 additions & 3 deletions apps/api/src/assignments/assignments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {
} from '@nestjs/common';
import { ApiOperation } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Assignment, BulkAssignmentPreflightResult } from '@opendatacapture/schemas/assignment';
import type {
Assignment,
BulkAssignmentPreflightResult,
DeleteBulkAssignmentsResult
} from '@opendatacapture/schemas/assignment';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import type { EmailDeliveryResult, MailTemplate } from '@opendatacapture/schemas/mail';

Expand All @@ -28,6 +32,7 @@ import { MailService } from '@/mail/mail.service';
import { AssignmentsService } from './assignments.service';
import { BulkAssignmentPreflightDto, CreateBulkAssignmentsDto } from './dto/bulk-assignment.dto';
import { CreateAssignmentDto } from './dto/create-assignment.dto';
import { DeleteBulkAssignmentsDto } from './dto/delete-bulk-assignments.dto';
import { SendAssignmentEmailDto } from './dto/send-assignment-email.dto';
import { UpdateAssignmentDto } from './dto/update-assignment.dto';

Expand Down Expand Up @@ -74,11 +79,26 @@ export class AssignmentsController {
return this.assignmentsService.createBulk(data, currentUser);
}

@ApiOperation({ summary: 'Delete Assignments in Bulk' })
@HttpCode(HttpStatus.OK)
@Post('bulk/delete')
@RouteAccess({ action: 'delete', subject: 'Assignment' })
deleteBulk(
@Body() data: DeleteBulkAssignmentsDto,
@CurrentUser('ability') ability?: AppAbility
): Promise<DeleteBulkAssignmentsResult> {
return this.assignmentsService.deleteBulk(data.ids, { ability });
}

@ApiOperation({ summary: 'Get All Assignments' })
@Get()
@RouteAccess({ action: 'read', subject: 'Assignment' })
find(@CurrentUser('ability') ability?: AppAbility, @Query('subjectId') subjectId?: string): Promise<Assignment[]> {
return this.assignmentsService.find({ subjectId }, { ability });
find(
@CurrentUser('ability') ability?: AppAbility,
@Query('groupId') groupId?: string,
@Query('subjectId') subjectId?: string
): Promise<Assignment[]> {
return this.assignmentsService.find({ groupId, subjectId }, { ability });
}

@ApiOperation({ summary: 'Email Assignment Link' })
Expand Down
35 changes: 33 additions & 2 deletions apps/api/src/assignments/assignments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,44 @@ export class AssignmentsService {
return staged.map(({ assignment }) => assignment);
}

async deleteBulk(ids: string[], { ability }: EntityOperationOptions = {}) {
const assignments = await this.assignmentModel.findMany({
where: {
AND: [accessibleQuery(ability, 'delete', 'Assignment')],
id: { in: ids },
status: { in: ['OUTSTANDING', 'EXPIRED'] }
}
});

const deletedIds: string[] = [];
const failedIds: string[] = [];

for (const assignment of assignments) {
try {
if (assignment.status === 'OUTSTANDING') {
await this.gatewayService.deleteRemoteAssignment(assignment.id);
}
await this.assignmentModel.delete({ where: { id: assignment.id } });
deletedIds.push(assignment.id);
} catch (err) {
this.loggingService.error({
error: err,
message: `Failed to delete assignment ${assignment.id}`
});
failedIds.push(assignment.id);
}
}

return { deletedCount: deletedIds.length, failedIds };
}

async find(
{ subjectId }: { subjectId?: string } = {},
{ groupId, subjectId }: { groupId?: string; subjectId?: string } = {},
{ ability }: EntityOperationOptions = {}
): Promise<Assignment[]> {
return this.assignmentModel.findMany({
where: {
AND: [accessibleQuery(ability, 'read', 'Assignment'), { subjectId }]
AND: [accessibleQuery(ability, 'read', 'Assignment'), { groupId, subjectId }]
}
});
}
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { ApiProperty } from '@nestjs/swagger';
import { $DeleteBulkAssignmentsData } from '@opendatacapture/schemas/assignment';
import type { DeleteBulkAssignmentsData } from '@opendatacapture/schemas/assignment';

@ValidationSchema($DeleteBulkAssignmentsData)
export class DeleteBulkAssignmentsDto implements DeleteBulkAssignmentsData {
@ApiProperty()
ids: string[];
}
153 changes: 153 additions & 0 deletions apps/web/src/__tests__/delete-remote-assignments.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React from 'react';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { DeleteRemoteAssignments } from '@/components/DeleteRemoteAssignments';

import '@/services/i18n';

const mocks = vi.hoisted(() => ({
assignments: [] as any[],
deleteMutate: vi.fn(),
instrumentInfo: [] as any[]
}));

vi.mock('@/hooks/useAssignmentsQuery', () => ({
ASSIGNMENTS_QUERY_KEY_PREFIX: 'assignments',
assignmentsQueryOptions: () => ({}),
useAssignmentsQuery: () => ({ data: mocks.assignments })
}));

vi.mock('@/hooks/useInstrumentInfoQuery', () => ({
useInstrumentInfoQuery: () => ({
data: mocks.instrumentInfo
})
}));

vi.mock('@/hooks/useDeleteBulkAssignmentsMutation', () => ({
useDeleteBulkAssignmentsMutation: () => ({
isPending: false,
mutate: mocks.deleteMutate
})
}));

const wrapper = ({ children }: { children: React.ReactNode }) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};

const renderComponent = (props: Partial<React.ComponentProps<typeof DeleteRemoteAssignments>> = {}) =>
render(<DeleteRemoteAssignments groupId="group-1" subjectIdDisplayLength={9} onBack={vi.fn()} {...props} />, {
wrapper
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
mocks.assignments = [];
mocks.instrumentInfo = [];
});

describe('DeleteRemoteAssignments', () => {
it('should show an empty state when there are no deletable assignments', () => {
mocks.assignments = [
{
createdAt: new Date('2026-01-01'),
expiresAt: new Date('2027-01-01'),
id: 'a-1',
instrumentId: 'i-1',
status: 'COMPLETE',
subjectId: 'subject-1'
}
];
renderComponent();
expect(screen.getByText(/no outstanding or expired/i)).toBeTruthy();
});

it('should list outstanding and expired assignments', () => {
mocks.assignments = [
{
createdAt: new Date('2026-01-01'),
expiresAt: new Date('2027-01-01'),
id: 'a-1',
instrumentId: 'i-1',
status: 'OUTSTANDING',
subjectId: 'subject-1'
},
{
createdAt: new Date('2025-01-01'),
expiresAt: new Date('2025-06-01'),
id: 'a-2',
instrumentId: 'i-1',
status: 'EXPIRED',
subjectId: 'subject-2'
},
{
createdAt: new Date('2025-01-01'),
expiresAt: new Date('2025-06-01'),
id: 'a-3',
instrumentId: 'i-1',
status: 'COMPLETE',
subjectId: 'subject-3'
}
];
mocks.instrumentInfo = [{ details: { title: 'Test Instrument' }, id: 'i-1' }];
renderComponent();
expect(screen.getAllByText('Test Instrument')).toHaveLength(2);
});

it('should disable the delete button when nothing is selected', () => {
mocks.assignments = [
{
createdAt: new Date('2026-01-01'),
expiresAt: new Date('2027-01-01'),
id: 'a-1',
instrumentId: 'i-1',
status: 'OUTSTANDING',
subjectId: 'subject-1'
}
];
renderComponent();
const button = screen.getByTestId('delete-selected-assignments');
expect(button.hasAttribute('disabled')).toBe(true);
});

it('should open a confirmation dialog when the delete button is clicked', () => {
mocks.assignments = [
{
createdAt: new Date('2026-01-01'),
expiresAt: new Date('2027-01-01'),
id: 'a-1',
instrumentId: 'i-1',
status: 'OUTSTANDING',
subjectId: 'subject-1'
}
];
renderComponent();
const checkbox = screen.getByTestId('delete-select-assignment-a-1');
fireEvent.click(checkbox);
fireEvent.click(screen.getByTestId('delete-selected-assignments'));
expect(screen.getByTestId('confirm-delete-assignments')).toBeTruthy();
});

it('should call the delete mutation with selected ids on confirmation', () => {
mocks.assignments = [
{
createdAt: new Date('2026-01-01'),
expiresAt: new Date('2027-01-01'),
id: 'a-1',
instrumentId: 'i-1',
status: 'OUTSTANDING',
subjectId: 'subject-1'
}
];
renderComponent();
fireEvent.click(screen.getByTestId('delete-select-assignment-a-1'));
fireEvent.click(screen.getByTestId('delete-selected-assignments'));
fireEvent.click(screen.getByTestId('confirm-delete-assignments'));
expect(mocks.deleteMutate).toHaveBeenCalledTimes(1);
expect(mocks.deleteMutate.mock.lastCall?.[0]).toMatchObject({ ids: ['a-1'] });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type BulkRemoteAssignmentWizardProps = {
/** Needed to turn a pasted custom identifier back into the scoped form it is stored under. */
groupName: string;
instruments: InstrumentOption[];
onBack?: () => void;
/** Group setting controlling how much of an identifier the subject picker shows. */
subjectIdDisplayLength: number;
subjects: Subject[];
Expand All @@ -50,6 +51,7 @@ export const BulkRemoteAssignmentWizard = ({
groupId,
groupName,
instruments,
onBack,
subjectIdDisplayLength,
subjects
}: BulkRemoteAssignmentWizardProps) => {
Expand All @@ -63,6 +65,7 @@ export const BulkRemoteAssignmentWizard = ({
selectedIds={wizard.subjectIds}
subjectIdDisplayLength={subjectIdDisplayLength}
subjects={subjects}
onBack={onBack}
onParsed={wizard.acceptParsed}
onSelectedChange={wizard.setSelectedSubjectIds}
onStepChange={wizard.goTo}
Expand Down Expand Up @@ -117,6 +120,15 @@ export const BulkRemoteAssignmentWizard = ({
})}
footer={
<React.Fragment>
<Button
className="mr-auto"
data-testid="bulk-create-more"
type="button"
variant="outline"
onClick={() => wizard.goTo('SOURCE')}
>
{t({ en: 'Create More', es: 'Crear más', fr: 'Créer plus' })}
</Button>
<Button
data-testid="bulk-copy-links"
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const ErrorList = ({ errors }: { errors: BulkParseError[] }) => {
}
return (
<div
className="border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-4"
className="bg-destructive text-destructive-foreground rounded-md border p-4"
data-testid="bulk-error-list"
role="alert"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,6 @@ export const ReviewStep = ({
onStepChange={onStepChange}
>
<div className="flex flex-col gap-4" data-testid="bulk-review-step">
<ErrorList errors={messages} />

<p className="text-sm font-medium" data-testid="bulk-review-summary">
{t({
en: `${subjectCount} subjects × ${timepoints.length} instruments = ${subjectCount * timepoints.length} assignments`,
Expand All @@ -133,8 +131,10 @@ export const ReviewStep = ({
</p>
<TimepointTable timepoints={timepoints} />

<ErrorList errors={messages} />

{hasConflict && (
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2 text-sm font-semibold">
<Checkbox
checked={allowDuplicates}
data-testid="bulk-allow-duplicates"
Expand Down
Loading
Loading