Skip to content

Commit 8366406

Browse files
committed
Merge branch 'main' of github.com:island-is/island.is into j-s/revoke-send-to-prison-admin-modal
2 parents d3473ca + 5f9b084 commit 8366406

File tree

59 files changed

+622
-132
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+622
-132
lines changed

apps/application-system/api/src/app/modules/application/lifecycle/application-lifecycle.service.ts

+43-30
Original file line numberDiff line numberDiff line change
@@ -151,39 +151,52 @@ export class ApplicationLifeCycleService {
151151
private async preparePrunedNotification(
152152
application: PruningApplication,
153153
): Promise<CreateHnippNotificationDto | null> {
154-
const template = await getApplicationTemplateByTypeId(application.typeId)
155-
const stateConfig = template.stateMachineConfig.states[application.state]
156-
const lifeCycle = stateConfig.meta?.lifecycle
157-
if (lifeCycle && lifeCycle.shouldBePruned && lifeCycle.pruneMessage) {
158-
try {
159-
const pruneMessage =
160-
typeof lifeCycle.pruneMessage === 'function'
161-
? lifeCycle.pruneMessage(application as ApplicationWithAttachments)
162-
: lifeCycle.pruneMessage
163-
const notification = {
164-
recipient: application.applicant,
165-
templateId: pruneMessage.notificationTemplateId,
166-
args: [
167-
{
168-
key: 'externalBody',
169-
value: pruneMessage.externalBody || '',
170-
},
171-
{
172-
key: 'internalBody',
173-
value: pruneMessage.internalBody || '',
174-
},
175-
],
176-
}
177-
return notification
178-
} catch (error) {
179-
this.logger.error(
180-
`Failed to prepare pruning notification for application ${application.id}`,
181-
error,
182-
)
154+
try {
155+
const template = await getApplicationTemplateByTypeId(application.typeId)
156+
if (!template) {
183157
return null
184158
}
159+
const stateConfig = template.stateMachineConfig.states[application.state]
160+
const lifeCycle = stateConfig.meta?.lifecycle
161+
if (lifeCycle && lifeCycle.shouldBePruned && lifeCycle.pruneMessage) {
162+
try {
163+
const pruneMessage =
164+
typeof lifeCycle.pruneMessage === 'function'
165+
? lifeCycle.pruneMessage(
166+
application as ApplicationWithAttachments,
167+
)
168+
: lifeCycle.pruneMessage
169+
const notification = {
170+
recipient: application.applicant,
171+
templateId: pruneMessage.notificationTemplateId,
172+
args: [
173+
{
174+
key: 'externalBody',
175+
value: pruneMessage.externalBody || '',
176+
},
177+
{
178+
key: 'internalBody',
179+
value: pruneMessage.internalBody || '',
180+
},
181+
],
182+
}
183+
return notification
184+
} catch (error) {
185+
this.logger.error(
186+
`Failed to prepare pruning notification for application ${application.id}`,
187+
error,
188+
)
189+
return null
190+
}
191+
}
192+
return null
193+
} catch (error) {
194+
this.logger.error(
195+
`Failed to get application template for application typeId ${application.typeId}`,
196+
error,
197+
)
198+
return null
185199
}
186-
return null
187200
}
188201

189202
private async sendPrunedNotification(

apps/judicial-system/api/infra/judicial-system-api.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ export const serviceSetup = (services: {
4747
},
4848
HIDDEN_FEATURES: {
4949
dev: '',
50-
staging: '',
51-
prod: '',
50+
staging: 'MULTIPLE_INDICTMENT_SUBTYPES',
51+
prod: 'MULTIPLE_INDICTMENT_SUBTYPES',
5252
},
5353
})
5454
.secrets({

apps/judicial-system/api/src/app/modules/indictment-count/dto/updateIndictmentCount.input.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { GraphQLJSONObject } from 'graphql-type-json'
44
import { Field, ID, InputType } from '@nestjs/graphql'
55

66
import type { SubstanceMap } from '@island.is/judicial-system/types'
7-
import { IndictmentCountOffense } from '@island.is/judicial-system/types'
7+
import {
8+
IndictmentCountOffense,
9+
IndictmentSubtype,
10+
} from '@island.is/judicial-system/types'
811

912
@InputType()
1013
export class UpdateIndictmentCountInput {
@@ -53,4 +56,11 @@ export class UpdateIndictmentCountInput {
5356
@IsOptional()
5457
@Field(() => String, { nullable: true })
5558
readonly legalArguments?: string
59+
60+
@Allow()
61+
@IsOptional()
62+
@IsArray()
63+
@IsEnum(IndictmentSubtype, { each: true })
64+
@Field(() => [IndictmentSubtype], { nullable: true })
65+
readonly indictmentCountSubtypes?: IndictmentSubtype[]
5666
}

apps/judicial-system/api/src/app/modules/indictment-count/models/indictmentCount.model.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { GraphQLJSONObject } from 'graphql-type-json'
33
import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'
44

55
import type { SubstanceMap } from '@island.is/judicial-system/types'
6-
import { IndictmentCountOffense } from '@island.is/judicial-system/types'
6+
import {
7+
IndictmentCountOffense,
8+
IndictmentSubtype,
9+
} from '@island.is/judicial-system/types'
710

811
registerEnumType(IndictmentCountOffense, { name: 'IndictmentCountOffense' })
12+
registerEnumType(IndictmentSubtype, { name: 'IndictmentSubtype' })
913

1014
@ObjectType()
1115
export class IndictmentCount {
@@ -41,4 +45,7 @@ export class IndictmentCount {
4145

4246
@Field(() => String, { nullable: true })
4347
readonly legalArguments?: string
48+
49+
@Field(() => [IndictmentSubtype], { nullable: true })
50+
readonly indictmentCountSubtypes?: IndictmentSubtype[]
4451
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict'
2+
3+
module.exports = {
4+
async up(queryInterface, Sequelize) {
5+
await queryInterface.sequelize.transaction((transaction) =>
6+
queryInterface.addColumn(
7+
'indictment_count',
8+
'indictment_count_subtypes',
9+
{
10+
type: Sequelize.ARRAY(Sequelize.STRING),
11+
allowNull: true,
12+
defaultValue: [],
13+
},
14+
{ transaction },
15+
),
16+
)
17+
},
18+
19+
async down(queryInterface) {
20+
await queryInterface.removeColumn(
21+
'indictment_count',
22+
'indictment_count_subtypes',
23+
)
24+
},
25+
}

apps/judicial-system/backend/src/app/modules/case/case.controller.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { CurrentCase } from './guards/case.decorator'
7070
import { CaseCompletedGuard } from './guards/caseCompleted.guard'
7171
import { CaseExistsGuard } from './guards/caseExists.guard'
7272
import { CaseReadGuard } from './guards/caseRead.guard'
73+
import { CaseTransitionGuard } from './guards/caseTransition.guard'
7374
import { CaseTypeGuard } from './guards/caseType.guard'
7475
import { CaseWriteGuard } from './guards/caseWrite.guard'
7576
import { MergedCaseExistsGuard } from './guards/mergedCaseExists.guard'
@@ -273,7 +274,13 @@ export class CaseController {
273274
return this.caseService.update(theCase, update, user) as Promise<Case> // Never returns undefined
274275
}
275276

276-
@UseGuards(JwtAuthGuard, CaseExistsGuard, RolesGuard, CaseWriteGuard)
277+
@UseGuards(
278+
JwtAuthGuard,
279+
CaseExistsGuard,
280+
RolesGuard,
281+
CaseWriteGuard,
282+
CaseTransitionGuard,
283+
)
277284
@RolesRules(
278285
prosecutorTransitionRule,
279286
prosecutorRepresentativeTransitionRule,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
InternalServerErrorException,
7+
} from '@nestjs/common'
8+
9+
import { User } from '@island.is/judicial-system/types'
10+
11+
import { getTransitionRule } from './caseTransitionRules'
12+
13+
@Injectable()
14+
// Used for more complex cases than just whether a role can perform a
15+
// transition overall, which is handled in the transition roles rules
16+
export class CaseTransitionGuard implements CanActivate {
17+
canActivate(context: ExecutionContext): boolean {
18+
const request = context.switchToHttp().getRequest()
19+
20+
const { transition } = request.body
21+
const theCase = request.case
22+
const user: User = request.user
23+
24+
// This shouldn't happen
25+
if (!theCase || !user) {
26+
throw new InternalServerErrorException('Missing case or user')
27+
}
28+
29+
const transitionRule = getTransitionRule(transition)
30+
31+
if (!transitionRule(theCase, user)) {
32+
throw new ForbiddenException('Forbidden transition')
33+
}
34+
35+
return true
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ForbiddenException } from '@nestjs/common'
2+
3+
import {
4+
CaseIndictmentRulingDecision,
5+
CaseTransition,
6+
CaseType,
7+
User,
8+
} from '@island.is/judicial-system/types'
9+
10+
import { Case } from '..'
11+
12+
type TransitionRule = (theCase: Case, user: User) => boolean
13+
14+
const defaultTransitionRule: TransitionRule = () => true
15+
16+
const completeTransitionRule: TransitionRule = (theCase, user) => {
17+
if (theCase.type !== CaseType.INDICTMENT) {
18+
throw new ForbiddenException(
19+
`Forbidden transition for ${theCase.type} cases`,
20+
)
21+
}
22+
23+
if (
24+
theCase.indictmentRulingDecision === CaseIndictmentRulingDecision.RULING ||
25+
theCase.indictmentRulingDecision === CaseIndictmentRulingDecision.DISMISSAL
26+
) {
27+
return user.id === theCase.judgeId
28+
}
29+
30+
return true
31+
}
32+
33+
const transitionRuleMap: Partial<Record<CaseTransition, TransitionRule>> = {
34+
[CaseTransition.COMPLETE]: completeTransitionRule,
35+
}
36+
37+
export const getTransitionRule = (
38+
transition: CaseTransition,
39+
): TransitionRule => {
40+
return transitionRuleMap[transition] || defaultTransitionRule
41+
}

apps/judicial-system/backend/src/app/modules/case/guards/rolesRules.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export const prosecutorTransitionRule: RolesRule = {
211211
const dto: TransitionCaseDto = request.body
212212
const theCase: Case = request.case
213213

214-
// Deny if something is missing - shuould never happen
214+
// Deny if something is missing - should never happen
215215
if (!user || !dto || !theCase) {
216216
return false
217217
}
@@ -258,7 +258,7 @@ export const defenderTransitionRule: RolesRule = {
258258
const dto: TransitionCaseDto = request.body
259259
const theCase: Case = request.case
260260

261-
// Deny if something is missing - shuould never happen
261+
// Deny if something is missing - should never happen
262262
if (!dto || !theCase) {
263263
return false
264264
}
@@ -393,7 +393,7 @@ export const districtCourtJudgeSignRulingRule: RolesRule = {
393393
const user: User = request.user
394394
const theCase: Case = request.case
395395

396-
// Deny if something is missing - shuould never happen
396+
// Deny if something is missing - should never happen
397397
if (!user || !theCase) {
398398
return false
399399
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
ExecutionContext,
3+
ForbiddenException,
4+
InternalServerErrorException,
5+
} from '@nestjs/common'
6+
7+
import {
8+
CaseIndictmentRulingDecision,
9+
CaseTransition,
10+
CaseType,
11+
} from '@island.is/judicial-system/types'
12+
13+
import { CaseTransitionGuard } from '../caseTransition.guard'
14+
15+
describe('CaseTransitionGuard', () => {
16+
let guard: CaseTransitionGuard
17+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
18+
let mockRequest: jest.Mock
19+
20+
beforeEach(() => {
21+
guard = new CaseTransitionGuard()
22+
mockRequest = jest.fn()
23+
})
24+
25+
const mockExecutionContext = (requestMock: unknown): ExecutionContext =>
26+
({
27+
switchToHttp: () => ({
28+
getRequest: () => requestMock,
29+
}),
30+
} as unknown as ExecutionContext)
31+
32+
const createMockCase = (
33+
type: CaseType,
34+
rulingDecision: unknown,
35+
judgeId: string,
36+
) => ({
37+
type,
38+
indictmentRulingDecision: rulingDecision,
39+
judgeId,
40+
})
41+
42+
it('should activate when the judge is the assigned judge', () => {
43+
const mockCase = createMockCase(
44+
CaseType.INDICTMENT,
45+
CaseIndictmentRulingDecision.RULING,
46+
'judgeId',
47+
)
48+
const context = mockExecutionContext({
49+
body: { transition: CaseTransition.COMPLETE },
50+
case: mockCase,
51+
user: { id: 'judgeId' },
52+
})
53+
54+
const result = guard.canActivate(context)
55+
56+
expect(result).toBe(true)
57+
})
58+
59+
it('should not activate when the user is not the assigned judge', () => {
60+
const mockCase = createMockCase(
61+
CaseType.INDICTMENT,
62+
CaseIndictmentRulingDecision.RULING,
63+
'judgeId',
64+
)
65+
const context = mockExecutionContext({
66+
body: { transition: CaseTransition.COMPLETE },
67+
case: mockCase,
68+
user: { id: 'differentJudgeId' },
69+
})
70+
71+
expect(() => guard.canActivate(context)).toThrow(ForbiddenException)
72+
})
73+
74+
it('should activate using the default rule for transitions not in the rule map', () => {
75+
const mockCase = createMockCase(CaseType.CUSTODY, null, 'someId')
76+
const context = mockExecutionContext({
77+
body: { transition: CaseTransition.SUBMIT },
78+
case: mockCase,
79+
user: { id: 'someId' },
80+
})
81+
82+
const result = guard.canActivate(context)
83+
84+
expect(result).toBe(true)
85+
})
86+
87+
it('should throw InternalServerErrorException when case or user is missing', () => {
88+
const context = mockExecutionContext({
89+
body: { transition: CaseTransition.COMPLETE },
90+
case: null,
91+
user: null,
92+
})
93+
94+
expect(() => guard.canActivate(context)).toThrow(
95+
InternalServerErrorException,
96+
)
97+
})
98+
})

0 commit comments

Comments
 (0)