Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"mongodb": "^6.15.0",
"msgpackr": "catalog:",
"neverthrow": "catalog:",
"nodemailer": "^7.0.12",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.14",
Expand All @@ -63,6 +64,7 @@
"devDependencies": {
"@nestjs/testing": "^11.0.11",
"@types/express": "^5.0.0",
"@types/nodemailer": "^7.0.5",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"mongodb-memory-server": "^10.3.0",
Expand Down
58 changes: 58 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,30 @@ type GroupSettings {
minimumAge Int?
}

// Mirrors $GroupEmailTemplateCategory in @opendatacapture/schemas so Prisma returns the
// narrowed enum rather than a bare string (which the Zod-typed Group records reject).
enum GroupEmailTemplateCategory {
REMOTE_ASSIGNMENT
INFORMATION
}

// A string authored in the instance's active languages; each field is optional so a
// template can be authored in a single language without requiring all of them.
type LocalizedString {
en String?
es String?
fr String?
}

// A group-manager-authored, categorized email template for a group's participants.
type GroupEmailTemplate {
id String
name String
category GroupEmailTemplateCategory
subject LocalizedString?
body LocalizedString?
}

model Group {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
Expand All @@ -124,6 +148,10 @@ model Group {
userIds String[] @db.ObjectId
users User[] @relation(fields: [userIds], references: [id])

emailTemplates GroupEmailTemplate[]
activeAssignmentEmailTemplateId String?
activeInformationTemplateId String?

@@map("GroupModel")
}

Expand Down Expand Up @@ -381,14 +409,44 @@ type BrandingConfig {
taglineFontSize Int?
}

// The admin-configurable SMTP settings used to send outgoing email. The `password`
// is stored here so the server can authenticate to the SMTP server; it is never
// returned to clients (see the mail endpoints, which strip it).
type MailConfig {
apiKey String
awsAccessKeyId String?
awsRegion String?
domain String?
enabled Boolean
encryption String
host String
password String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

password and apiKey are persisted in plaintext. For SES, apiKey holds the AWS secret access key, so a database backup or a read-only mongo credential leaks credentials that can send mail as the institution — and, for a broadly-scoped IAM key, potentially more.

The comment above correctly notes these are never returned to clients, but that isn't the exposure that matters; the DB is.

Options, roughly in order of effort: encrypt at rest with a key from $Env (envelope-encrypt just these two fields), or read the secret from an env var and store only non-secret config here. If plaintext is a deliberate accepted risk for this deployment model, please say so explicitly in the comment so the next reader doesn't have to re-derive the decision.

port Int
provider String?
region String?
senderAddress String
senderName String?
transport String?
username String
}

// An editable email template whose body may contain {{variable}} placeholders.
type MailTemplate {
body LocalizedString?
subject LocalizedString?
}

model SetupState {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages String[]
branding BrandingConfig?
isDemo Boolean
isExperimentalFeaturesEnabled Boolean?
isSetup Boolean
mailConfig MailConfig?
newUserEmailTemplate MailTemplate?

@@map("SetupStateModel")
}
149 changes: 149 additions & 0 deletions apps/api/src/assignments/__tests__/assignments.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
import { Test } from '@nestjs/testing';
import type { MailLanguage } from '@opendatacapture/schemas/mail';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import { beforeEach, describe, expect, it } from 'vitest';

import type { AppAbility } from '@/auth/auth.types';
import { GroupsService } from '@/groups/groups.service';
import { MailService } from '@/mail/mail.service';

import { AssignmentsController } from '../assignments.controller';
import { AssignmentsService } from '../assignments.service';

const ability = {} as AppAbility;

const assignment = {
expiresAt: new Date('2026-08-01T12:00:00.000Z'),
groupId: 'group-1',
id: 'assignment-1',
url: 'https://gateway.example.org/assignments/abc'
};

const customTemplate = {
body: { en: 'Custom body {{url}}', fr: 'Corps personnalisé {{url}}' },
category: 'REMOTE_ASSIGNMENT',
id: 'tpl-1',
name: 'Custom',
subject: { en: 'Custom subject', fr: 'Objet personnalisé' }
};

describe('AssignmentsController', () => {
let assignmentsController: AssignmentsController;
let assignmentsService: MockedInstance<AssignmentsService>;
let groupsService: MockedInstance<GroupsService>;
let mailService: MockedInstance<MailService>;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AssignmentsController],
providers: [
MockFactory.createForService(AssignmentsService),
MockFactory.createForService(GroupsService),
MockFactory.createForService(MailService)
]
}).compile();
assignmentsController = moduleRef.get(AssignmentsController);
assignmentsService = moduleRef.get(AssignmentsService);
groupsService = moduleRef.get(GroupsService);
mailService = moduleRef.get(MailService);
});

it('should be defined', () => {
expect(assignmentsController).toBeDefined();
});

describe('sendEmail', () => {
const sendEmail = (body: { language: MailLanguage; recipient: string; templateId?: null | string }) =>
assignmentsController.sendEmail('assignment-1', body, ability);

it('uses the built-in default template when the assignment has no group', async () => {
assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null });
await sendEmail({ language: 'en', recipient: 'p@x.org' });
expect(groupsService.findById).not.toHaveBeenCalled();
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en,
expiresAt: '2026-08-01',
recipient: 'p@x.org',
subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en,
url: `${assignment.url}?lang=en`
});
});

it("uses the group's active template when no template id is given", async () => {
assignmentsService.findById.mockResolvedValueOnce(assignment);
groupsService.findById.mockResolvedValueOnce({
activeAssignmentEmailTemplateId: 'tpl-1',
emailTemplates: [customTemplate]
});
await sendEmail({ language: 'en', recipient: 'p@x.org' });
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: customTemplate.body.en,
subject: customTemplate.subject.en
});
});

it('uses the explicitly requested template over the active one', async () => {
assignmentsService.findById.mockResolvedValueOnce(assignment);
groupsService.findById.mockResolvedValueOnce({
activeAssignmentEmailTemplateId: 'tpl-other',
emailTemplates: [customTemplate]
});
await sendEmail({ language: 'en', recipient: 'p@x.org', templateId: 'tpl-1' });
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: customTemplate.body.en,
subject: customTemplate.subject.en
});
});

it('uses the default template when the template id is null, even if an active template is set', async () => {
assignmentsService.findById.mockResolvedValueOnce(assignment);
groupsService.findById.mockResolvedValueOnce({
activeAssignmentEmailTemplateId: 'tpl-1',
emailTemplates: [customTemplate]
});
await sendEmail({ language: 'en', recipient: 'p@x.org', templateId: null });
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en,
subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en
});
});

it('ignores a matching template of the wrong category', async () => {
assignmentsService.findById.mockResolvedValueOnce(assignment);
groupsService.findById.mockResolvedValueOnce({
activeAssignmentEmailTemplateId: 'tpl-1',
emailTemplates: [{ ...customTemplate, category: 'INFORMATION' }]
});
await sendEmail({ language: 'en', recipient: 'p@x.org' });
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en,
subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en
});
});

it('sends the French content when language is fr', async () => {
assignmentsService.findById.mockResolvedValueOnce(assignment);
groupsService.findById.mockResolvedValueOnce({
activeAssignmentEmailTemplateId: 'tpl-1',
emailTemplates: [customTemplate]
});
await sendEmail({ language: 'fr', recipient: 'p@x.org' });
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: customTemplate.body.fr,
subject: customTemplate.subject.fr
});
});

it('returns the delivery result from the mail service', async () => {
assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null });
mailService.sendAssignmentEmail.mockResolvedValueOnce({
message: 'rendered',
recipient: 'p@x.org',
status: 'SENT'
});
await expect(sendEmail({ language: 'en', recipient: 'p@x.org' })).resolves.toMatchObject({ status: 'SENT' });
});
});
});
43 changes: 42 additions & 1 deletion apps/api/src/assignments/assignments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@ import type { RequestUser } from '@douglasneuroinformatics/libnest';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiOperation } from '@nestjs/swagger';
import type { Assignment } from '@opendatacapture/schemas/assignment';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import type { EmailDeliveryResult, MailTemplate } from '@opendatacapture/schemas/mail';

import type { AppAbility } from '@/auth/auth.types';
import { RouteAccess } from '@/core/decorators/route-access.decorator';
import { GroupsService } from '@/groups/groups.service';
import { MailService } from '@/mail/mail.service';
import { pickLocale } from '@/mail/mail.utils';

import { AssignmentsService } from './assignments.service';
import { CreateAssignmentDto } from './dto/create-assignment.dto';
import { SendAssignmentEmailDto } from './dto/send-assignment-email.dto';
import { UpdateAssignmentDto } from './dto/update-assignment.dto';

@Controller('assignments')
export class AssignmentsController {
constructor(private readonly assignmentsService: AssignmentsService) {}
constructor(
private readonly assignmentsService: AssignmentsService,
private readonly groupsService: GroupsService,
private readonly mailService: MailService
) {}

@ApiOperation({ summary: 'Create Assignment' })
@Post()
Expand All @@ -29,6 +39,37 @@ export class AssignmentsController {
return this.assignmentsService.find({ subjectId }, { ability });
}

@ApiOperation({ summary: 'Email Assignment Link' })
@Post(':id/email')
@RouteAccess({ action: 'update', subject: 'Assignment' })
async sendEmail(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two things on this endpoint:

Recipient is unconstrained. RouteAccess({ action: 'update', subject: 'Assignment' }) means any user who can update an assignment can make the instance's SMTP identity deliver to an arbitrary address, unthrottled. The content is template-constrained so it isn't a general open relay, but it is a free "send mail from <institution>" primitive, and the assignment URL it carries is a live credential. A rate limit (per user and per recipient) would be cheap insurance.

expiresAt is formatted in UTC. new Date(assignment.expiresAt).toISOString().slice(0, 10) renders the date in UTC, so an assignment expiring at 2026-08-01T02:00Z reads as "expires 2026-08-01" to a recipient in Montréal for whom it already expired on July 31 local. Given the participant acts on this date, formatting in the instance's timezone (or including the time) would avoid off-by-one-day support tickets.

@Param('id') id: string,
@Body() { language, recipient, templateId }: SendAssignmentEmailDto,
@CurrentUser('ability') ability: AppAbility
): Promise<EmailDeliveryResult> {
const assignment = await this.assignmentsService.findById(id, { ability });
// Choose the requested template if given, else the group's active one; either way falling back
// to the built-in default when the id resolves to nothing (e.g. null selects the default).
let template: MailTemplate = { ...DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE };
if (assignment.groupId) {
const group = await this.groupsService.findById(assignment.groupId, { ability });
const targetId = templateId === undefined ? group.activeAssignmentEmailTemplateId : templateId;
const chosen = group.emailTemplates?.find(
({ category, id }) => category === 'REMOTE_ASSIGNMENT' && id === targetId
);
if (chosen?.body && chosen?.subject) {
template = { body: chosen.body, subject: chosen.subject };
}
}
return this.mailService.sendAssignmentEmail({
body: pickLocale(template.body, language),
expiresAt: new Date(assignment.expiresAt).toISOString().slice(0, 10),
recipient,
subject: pickLocale(template.subject, language),
url: `${assignment.url}?lang=${language}`
});
}

@ApiOperation({ summary: 'Update Assignment' })
@Patch(':id')
@RouteAccess({ action: 'update', subject: 'Assignment' })
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/assignments/assignments.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { forwardRef, Module } from '@nestjs/common';

import { GatewayModule } from '@/gateway/gateway.module';
import { GroupsModule } from '@/groups/groups.module';
import { MailModule } from '@/mail/mail.module';

import { AssignmentsController } from './assignments.controller';
import { AssignmentsService } from './assignments.service';

@Module({
controllers: [AssignmentsController],
exports: [AssignmentsService],
imports: [forwardRef(() => GatewayModule)],
imports: [forwardRef(() => GatewayModule), GroupsModule, MailModule],
providers: [AssignmentsService]
})
export class AssignmentsModule {}
19 changes: 19 additions & 0 deletions apps/api/src/assignments/dto/send-assignment-email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { ApiProperty } from '@nestjs/swagger';
import { $SendAssignmentEmailData } from '@opendatacapture/schemas/mail';
import type { MailLanguage, SendAssignmentEmailData } from '@opendatacapture/schemas/mail';

@ValidationSchema($SendAssignmentEmailData)
export class SendAssignmentEmailDto implements SendAssignmentEmailData {
@ApiProperty({ description: 'The language to send the email in', enum: ['en', 'es', 'fr'] })
language: MailLanguage;

@ApiProperty({ description: "The participant's email address" })
recipient: string;

@ApiProperty({
description: 'Template id to use; null for the built-in default, omitted to use the group active template',
required: false
})
templateId?: null | string;
}
5 changes: 4 additions & 1 deletion apps/api/src/groups/dto/update-group.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { $UpdateGroupData } from '@opendatacapture/schemas/group';
import type { GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture/schemas/group';
import type { GroupEmailTemplate, GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture/schemas/group';

@ValidationSchema($UpdateGroupData)
export class UpdateGroupDto implements UpdateGroupData {
accessibleInstrumentIds?: string[];
activeAssignmentEmailTemplateId?: null | string;
activeInformationTemplateId?: null | string;
emailTemplates?: GroupEmailTemplate[];
instrumentRepoIds?: string[];
name?: string;
settings?: Partial<GroupSettings>;
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class GroupsService {

async updateById(
id: string,
{ accessibleInstrumentIds, instrumentRepoIds, settings, ...data }: UpdateGroupDto,
{ accessibleInstrumentIds, emailTemplates, instrumentRepoIds, settings, ...data }: UpdateGroupDto,
{ ability }: EntityOperationOptions = {}
) {
const where: Prisma.GroupWhereInput = { AND: [accessibleQuery(ability, 'update', 'Group')], id };
Expand Down Expand Up @@ -107,6 +107,8 @@ export class GroupsService {
set: validInstrumentIds.map((id) => ({ id }))
}
: undefined,
// Composite list fields must be replaced via `set` in the MongoDB connector.
emailTemplates: emailTemplates ? { set: emailTemplates } : undefined,
instrumentRepos: instrumentRepoIds
? {
set: instrumentRepoIds.map((id) => ({ id }))
Expand Down
Loading
Loading